// src/builtins.rs
#![allow(dead_code, unreachable_code, unused_variables)]
use anyhow::{Context, Result};
use serde_json;
use std::collections::{BTreeMap, HashMap};
use std::fs;
use sysinfo::{Disks, Networks, System};
use walkdir::WalkDir;
use crate::{
ai::a2ui::{
A2UIEvent, A2UIEventType, NotificationLevel, PromptResponse, RenderContent, A2UI_CHANNEL,
},
config::{get_config, reload_config, ShellConfig},
env::Env,
eval::eval_expr,
evolution::{CrossoverStrategy, EvolutionConfig, FitnessResult, Population, SelectionStrategy},
neural::{Activation, ConsensusNetwork, NeuralNetwork},
os_tools::{execute_tool_safe, OSToolsDatabase, OperatingSystem, ToolCategory},
rl::{
ActorCriticAgent, DQNAgent, GridWorld, PolicyGradientAgent, QLearningAgent, ReplayBuffer,
SarsaAgent,
},
rlm::{run_recursive, run_recursive_with_model, RlmConfig},
security::{
check_file_size_limit, check_rate_limit, validate_ai_prompt, validate_hostname_or_ip,
validate_read_path, validate_sh_allowed,
},
value::{Lambda, Value},
};
use std::time::Duration;
// Fast builtin lookup using hash map
lazy_static::lazy_static! {
/// Public accessor for builtin lookup table (used by agent_api for dynamic discovery)
// Fx, not SipHash: looked up on every builtin call, keyed by names the
// shell itself defines. Its iteration order was never stable either way.
pub static ref BUILTIN_LOOKUP: rustc_hash::FxHashMap<&'static str, usize> = {
let mut map = rustc_hash::FxHashMap::default();
// General functions
map.insert("help", 0);
map.insert("call", 1);
map.insert("clear", 2);
map.insert("echo", 3);
map.insert("print", 4);
map.insert("http_get", 5);
// Option constructors
map.insert("Some", 6);
map.insert("None", 7);
// File system
map.insert("ls", 8);
map.insert("list", 8); // alias
map.insert("pwd", 9);
map.insert("cat", 10);
map.insert("read_text", 11);
map.insert("head", 12);
map.insert("tail", 13);
map.insert("find", 14);
map.insert("sort", 15);
map.insert("uniq", 16);
map.insert("wc", 17);
map.insert("grep", 18);
// Data/pipelines
map.insert("map", 19);
map.insert("where", 20);
map.insert("reduce", 21);
map.insert("take", 22);
map.insert("keys", 23);
map.insert("len", 24);
map.insert("length", 24); // alias
map.insert("type_of", 25);
map.insert("typeof", 25); // alias
// Array functions
map.insert("first", 26);
map.insert("last", 27);
map.insert("any", 28);
map.insert("all", 29);
// MCP functions
map.insert("mcp_servers", 30);
map.insert("mcp-servers", 30); // alias
map.insert("mcp_detect", 31);
map.insert("mcp-detect", 31); // alias
map.insert("mcp_cache_clear", 32);
map.insert("mcp-cache-clear", 32); // alias
map.insert("mcp_cache_status", 33);
map.insert("mcp-cache-status", 33); // alias
// String functions
map.insert("split", 34);
map.insert("join", 35);
map.insert("trim", 36);
map.insert("upper", 37);
map.insert("lower", 38);
map.insert("replace", 39);
map.insert("contains", 40);
map.insert("starts_with", 41);
map.insert("ends_with", 42);
// Array functions (extended)
map.insert("flatten", 43);
map.insert("reverse", 44);
map.insert("slice", 45);
map.insert("range", 46);
map.insert("zip", 47);
map.insert("push", 48);
map.insert("concat", 49);
// Math functions
map.insert("abs", 50);
map.insert("min", 51);
map.insert("max", 52);
map.insert("floor", 53);
map.insert("ceil", 54);
map.insert("round", 55);
map.insert("sqrt", 56);
map.insert("pow", 57);
// Utility functions
map.insert("exit", 58);
map.insert("env", 59);
map.insert("set_env", 60);
map.insert("sleep", 61);
map.insert("time", 62);
map.insert("json_parse", 63);
map.insert("json_stringify", 64);
// Syntax Knowledge Base functions
map.insert("syntax_get", 65);
map.insert("syntax_list", 66);
map.insert("syntax_search", 67);
map.insert("syntax_add", 68);
map.insert("syntax_categories", 69);
map.insert("ab_encode", 70);
map.insert("ab_decode", 71);
// Neural Network functions (72-82)
map.insert("nn_create", 72);
map.insert("nn_forward", 73);
map.insert("nn_mutate", 74);
map.insert("nn_crossover", 75);
map.insert("nn_params", 76);
map.insert("nn_set_params", 77);
map.insert("nn_layers", 78);
map.insert("nn_info", 79);
map.insert("consensus_net", 80);
map.insert("consensus_vote", 81);
map.insert("activation", 82);
// Evolution functions (83-92)
map.insert("population", 83);
map.insert("evolve", 84);
map.insert("evolve_step", 85);
map.insert("fitness", 86);
map.insert("best_individual", 87);
map.insert("evolution_stats", 88);
map.insert("selection_strategy", 89);
map.insert("crossover_strategy", 90);
map.insert("evolution_config", 91);
map.insert("coevolve", 92);
// Reinforcement Learning functions (93-107)
map.insert("rl_agent", 93);
map.insert("rl_action", 94);
map.insert("rl_update", 95);
map.insert("rl_sarsa_agent", 96);
map.insert("rl_sarsa_update", 97);
map.insert("rl_pg_agent", 98);
map.insert("rl_pg_step", 99);
map.insert("rl_pg_episode_end", 100);
map.insert("rl_ac_agent", 101);
map.insert("rl_ac_update", 102);
map.insert("rl_dqn_agent", 103);
map.insert("rl_dqn_step", 104);
map.insert("rl_replay_buffer", 105);
map.insert("rl_gridworld", 106);
map.insert("rl_env_step", 107);
// OS Tools functions (108-112)
map.insert("tools", 108);
map.insert("tool_list", 108); // alias
map.insert("os_tools", 108); // alias for tools
map.insert("tool_info", 109);
map.insert("tool_schema", 110);
map.insert("tool_schemas", 110); // alias
map.insert("tool_exec", 111);
map.insert("tool_execute", 111); // alias
map.insert("tool_search", 112);
// Recursive Language Model functions (113-116)
map.insert("rlm_agent", 113);
map.insert("recursive_agent", 113); // alias
map.insert("rlm_config", 114);
map.insert("rlm_stats", 115);
map.insert("rlm_spawn", 116);
map.insert("spawn_agent", 116); // alias
// MCP Server/Client functions (117-122)
map.insert("mcp_server", 117);
map.insert("mcp_tools", 118);
map.insert("mcp_list_tools", 118); // alias
map.insert("mcp_call", 119);
map.insert("mcp_call_tool", 119); // alias
map.insert("mcp_resources", 120);
map.insert("mcp_list_resources", 120); // alias
map.insert("mcp_prompts", 121);
map.insert("mcp_list_prompts", 121); // alias
map.insert("mcp_connect", 122);
map.insert("mcp_client", 122); // alias
// New aspirational features (123-131)
map.insert("sh", 123);
map.insert("shell", 123); // alias
map.insert("now", 124);
map.insert("timestamp", 124); // alias
map.insert("sort_by", 125);
map.insert("save_json", 126);
map.insert("write_json", 126); // alias
map.insert("ai_backends", 127);
map.insert("mcp_server_start", 128);
map.insert("agent_with_mcp", 129);
map.insert("each", 130); // alias for map with side effects
map.insert("in", 131); // membership test
// Additional utility functions (132-136)
map.insert("values", 132);
map.insert("sum", 133);
map.insert("unique", 134);
map.insert("avg", 135);
map.insert("mean", 135); // alias for avg
map.insert("product", 136);
// Configuration functions (137-143)
map.insert("config", 137);
map.insert("config_get", 138);
map.insert("config_set", 139);
map.insert("config_path", 140);
map.insert("config_init", 141);
map.insert("config_reload", 142);
map.insert("themes", 143);
// Plugin functions (144-150)
map.insert("plugins", 144);
map.insert("plugin_list", 144); // alias
map.insert("plugin_info", 145);
map.insert("plugin_enable", 146);
map.insert("plugin_disable", 147);
map.insert("plugin_load", 148);
map.insert("plugin_unload", 149);
map.insert("plugin_categories", 150);
// Error handling functions (151)
map.insert("is_error", 151);
// Debugging tools (152-156)
map.insert("debug", 152);
map.insert("dbg", 152); // alias
map.insert("assert", 153);
map.insert("trace", 154);
map.insert("type_assert", 155);
map.insert("assert_type", 155); // alias
map.insert("inspect", 156);
// Platform detection functions (157-163)
map.insert("platform", 157);
map.insert("is_windows", 158);
map.insert("is_linux", 159);
map.insert("is_macos", 160);
map.insert("is_unix", 161);
map.insert("is_bsd", 162);
map.insert("platform_module", 163);
// Feature flag functions (164-168)
map.insert("features", 164);
map.insert("feature_list", 164); // alias
map.insert("feature_enabled", 165);
map.insert("has_feature", 165); // alias
map.insert("feature_enable", 166);
map.insert("feature_disable", 167);
map.insert("feature_set", 168);
// Distributed Computing functions (169-180)
map.insert("cluster_create", 169);
map.insert("cluster", 169); // alias
map.insert("cluster_add_node", 170);
map.insert("add_node", 170); // alias
map.insert("cluster_remove_node", 171);
map.insert("remove_node", 171); // alias
map.insert("cluster_status", 172);
map.insert("cluster_nodes", 173);
map.insert("nodes", 173); // alias
map.insert("job_submit", 174);
map.insert("submit", 174); // alias
map.insert("job_status", 175);
map.insert("job_cancel", 176);
map.insert("job_results", 177);
map.insert("results", 177); // alias
map.insert("job_list", 178);
map.insert("jobs", 178); // alias
map.insert("remote_exec", 179);
map.insert("exec_remote", 179); // alias
map.insert("aggregate_results", 180);
map.insert("aggregate", 180); // alias
// Advanced AI functions (181-192)
map.insert("rag_index", 181);
map.insert("index_docs", 181); // alias
map.insert("rag_search", 182);
map.insert("search_docs", 182); // alias
map.insert("rag_query", 183);
map.insert("rag", 183); // alias
map.insert("rag_clear", 184);
map.insert("kg_add", 185);
map.insert("add_entity", 185); // alias
map.insert("kg_relate", 186);
map.insert("add_relation", 186); // alias
map.insert("kg_query", 187);
map.insert("query_kg", 187); // alias
map.insert("kg_entities", 188);
map.insert("entities", 188); // alias
map.insert("kg_relations", 189);
map.insert("relations", 189); // alias
map.insert("semantic_cache", 190);
map.insert("cache_ai", 190); // alias
map.insert("semantic_cache_get", 191);
map.insert("cache_get", 191); // alias
map.insert("semantic_cache_clear", 192);
map.insert("cache_clear_ai", 192); // alias
// 193-199: RBAC (Role-Based Access Control)
map.insert("role_create", 193);
map.insert("create_role", 193); // alias
map.insert("role_delete", 194);
map.insert("delete_role", 194); // alias
map.insert("role_grant", 195);
map.insert("grant_permission", 195); // alias
map.insert("role_revoke", 196);
map.insert("revoke_permission", 196); // alias
map.insert("user_roles", 197);
map.insert("get_roles", 197); // alias
map.insert("check_permission", 198);
map.insert("has_permission", 198); // alias
map.insert("roles_list", 199);
map.insert("list_roles", 199); // alias
// 200-203: Audit Logging
map.insert("audit_log", 200);
map.insert("log_audit", 200); // alias
map.insert("audit_query", 201);
map.insert("query_audit", 201); // alias
map.insert("audit_export", 202);
map.insert("export_audit", 202); // alias
map.insert("audit_stats", 203);
map.insert("audit_statistics", 203); // alias
// 204-208: SSO Integration
map.insert("sso_init", 204);
map.insert("init_sso", 204); // alias
map.insert("sso_auth", 205);
map.insert("sso_authenticate", 205); // alias
map.insert("sso_validate", 206);
map.insert("validate_sso", 206); // alias
map.insert("sso_logout", 207);
map.insert("logout_sso", 207); // alias
map.insert("sso_status", 208);
map.insert("sso_info", 208); // alias
// 209-210: Compliance Reporting
map.insert("compliance_check", 209);
map.insert("check_compliance", 209); // alias
map.insert("compliance_report", 210);
map.insert("generate_compliance", 210); // alias
// 211-214: Model Fine-tuning
map.insert("finetune_start", 211);
map.insert("start_finetune", 211); // alias
map.insert("finetune_status", 212);
map.insert("finetune_progress", 212); // alias
map.insert("finetune_list", 213);
map.insert("list_finetunes", 213); // alias
map.insert("finetune_cancel", 214);
map.insert("cancel_finetune", 214); // alias
// 215-229: A2UI (Agent-to-User Interface) functions
map.insert("a2ui_notify", 215);
map.insert("notify_user", 215); // alias
map.insert("a2ui_prompt", 216);
map.insert("prompt_user", 216); // alias
map.insert("a2ui_confirm", 217);
map.insert("confirm_user", 217); // alias
map.insert("a2ui_select", 218);
map.insert("select_user", 218); // alias
map.insert("a2ui_progress", 219);
map.insert("progress_update", 219); // alias
map.insert("a2ui_progress_complete", 220);
map.insert("progress_done", 220); // alias
map.insert("a2ui_render", 221);
map.insert("render_content", 221); // alias
map.insert("a2ui_render_table", 222);
map.insert("render_table", 222); // alias
map.insert("a2ui_render_code", 223);
map.insert("render_code", 223); // alias
map.insert("a2ui_toast", 224);
map.insert("toast", 224); // alias
map.insert("a2ui_status", 225);
map.insert("status_bar", 225); // alias
map.insert("a2ui_clear", 226);
map.insert("ui_clear", 226); // alias
map.insert("a2ui_agent_started", 227);
map.insert("agent_started", 227); // alias
map.insert("a2ui_agent_completed", 228);
map.insert("agent_completed", 228); // alias
map.insert("a2ui_agent_thinking", 229);
map.insert("agent_thinking", 229); // alias
// 230-249: Process Management
map.insert("proc_list", 230);
map.insert("proc_kill", 231);
map.insert("proc_info", 232);
map.insert("proc_spawn", 233);
map.insert("proc_wait", 234);
map.insert("proc_exists", 235);
map.insert("proc_children", 236);
map.insert("proc_parent", 237);
map.insert("proc_priority", 238);
map.insert("proc_set_priority", 239);
map.insert("proc_cpu_usage", 240);
map.insert("proc_mem_usage", 241);
map.insert("proc_threads", 242);
map.insert("proc_env", 243);
map.insert("proc_cwd", 244);
map.insert("proc_exe", 245);
map.insert("proc_cmdline", 246);
map.insert("proc_start_time", 247);
map.insert("proc_suspend", 248);
map.insert("proc_resume", 249);
map.insert("ps", 230);
map.insert("kill", 231);
// 250-269: Filesystem Extended
map.insert("fs_stat", 250);
map.insert("fs_lstat", 251);
map.insert("fs_chmod", 252);
map.insert("fs_chown", 253);
map.insert("fs_link", 254);
map.insert("fs_symlink", 255);
map.insert("fs_readlink", 256);
map.insert("fs_realpath", 257);
map.insert("fs_tempfile", 258);
map.insert("fs_tempdir", 259);
map.insert("fs_watch", 260);
map.insert("fs_unwatch", 261);
map.insert("fs_glob", 262);
map.insert("fs_walk", 263);
map.insert("fs_tree", 264);
map.insert("fs_du", 265);
map.insert("fs_df", 266);
map.insert("fs_mount", 267);
map.insert("fs_unmount", 268);
map.insert("fs_mounts", 269);
map.insert("stat", 250);
map.insert("glob", 262);
map.insert("tree", 264);
map.insert("du", 265);
map.insert("df", 266);
// 270-289: Network Operations
map.insert("net_interfaces", 270);
map.insert("net_ip", 271);
map.insert("net_dns_lookup", 272);
map.insert("net_dns_reverse", 273);
map.insert("net_ping", 274);
map.insert("net_traceroute", 275);
map.insert("net_connections", 276);
map.insert("net_listen", 277);
map.insert("net_connect", 278);
map.insert("net_send", 279);
map.insert("net_recv", 280);
map.insert("net_close", 281);
map.insert("net_ports", 282);
map.insert("net_arp", 283);
map.insert("net_route", 284);
map.insert("net_stats", 285);
map.insert("net_bandwidth", 286);
map.insert("net_latency", 287);
map.insert("net_scan", 288);
map.insert("net_whois", 289);
map.insert("ping", 274);
map.insert("ifconfig", 270);
// 290-309: System Information
map.insert("sys_info", 290);
map.insert("sys_hostname", 291);
map.insert("sys_os", 292);
map.insert("sys_arch", 293);
map.insert("sys_kernel", 294);
map.insert("sys_uptime", 295);
map.insert("sys_boot_time", 296);
map.insert("sys_cpu_info", 297);
map.insert("sys_cpu_count", 298);
map.insert("sys_cpu_freq", 299);
map.insert("sys_mem_info", 300);
map.insert("sys_swap_info", 301);
map.insert("sys_disk_info", 302);
map.insert("sys_load_avg", 303);
map.insert("sys_users", 304);
map.insert("sys_groups", 305);
map.insert("sys_user_info", 306);
map.insert("sys_env_all", 307);
map.insert("sys_locale", 308);
map.insert("sys_timezone", 309);
map.insert("hostname", 291);
map.insert("uptime", 295);
// Was 304, `sys_users`: every account on the machine, not the caller.
map.insert("whoami", 1157);
// 310-329: Service & Daemon Management
map.insert("svc_list", 310);
map.insert("svc_status", 311);
map.insert("svc_start", 312);
map.insert("svc_stop", 313);
map.insert("svc_restart", 314);
map.insert("svc_enable", 315);
map.insert("svc_disable", 316);
map.insert("svc_logs", 317);
map.insert("svc_info", 318);
map.insert("svc_create", 319);
map.insert("svc_delete", 320);
map.insert("cron_list", 321);
map.insert("cron_add", 322);
map.insert("cron_remove", 323);
map.insert("cron_enable", 324);
map.insert("cron_disable", 325);
map.insert("at_schedule", 326);
map.insert("at_list", 327);
map.insert("at_remove", 328);
map.insert("startup_list", 329);
map.insert("service", 311);
map.insert("systemctl", 311);
// 330-349: Archive & Compression
map.insert("zip_create", 330);
map.insert("zip_extract", 331);
map.insert("zip_list", 332);
map.insert("zip_add", 333);
map.insert("tar_create", 334);
map.insert("tar_extract", 335);
map.insert("tar_list", 336);
map.insert("gzip_compress", 337);
map.insert("gzip_decompress", 338);
map.insert("bzip2_compress", 339);
map.insert("bzip2_decompress", 340);
map.insert("xz_compress", 341);
map.insert("xz_decompress", 342);
map.insert("zstd_compress", 343);
map.insert("zstd_decompress", 344);
map.insert("archive_info", 345);
map.insert("archive_test", 346);
map.insert("compress", 337);
map.insert("decompress", 338);
map.insert("unzip", 331);
// 350-369: User & Permission Management
map.insert("user_list", 350);
map.insert("user_add", 351);
map.insert("user_del", 352);
map.insert("user_mod", 353);
map.insert("user_passwd", 354);
map.insert("user_lock", 355);
map.insert("user_unlock", 356);
map.insert("group_list", 357);
map.insert("group_add", 358);
map.insert("group_del", 359);
map.insert("group_mod", 360);
map.insert("group_members", 361);
map.insert("perm_get", 362);
map.insert("perm_set", 363);
map.insert("perm_check", 364);
map.insert("acl_get", 365);
map.insert("acl_set", 366);
map.insert("sudo_check", 367);
map.insert("sudo_exec", 368);
map.insert("capabilities", 369);
map.insert("useradd", 351);
map.insert("userdel", 352);
map.insert("chmod", 252);
// 370-389: Package Management
map.insert("pkg_list", 370);
map.insert("pkg_search", 371);
map.insert("pkg_info", 372);
map.insert("pkg_install", 373);
map.insert("pkg_remove", 374);
map.insert("pkg_update", 375);
map.insert("pkg_upgrade", 376);
map.insert("pkg_clean", 377);
map.insert("pkg_sources", 378);
map.insert("pkg_add_source", 379);
map.insert("pkg_files", 380);
map.insert("pkg_owner", 381);
map.insert("pkg_verify", 382);
map.insert("pkg_deps", 383);
map.insert("pkg_rdeps", 384);
map.insert("pkg_changelog", 385);
map.insert("pkg_hold", 386);
map.insert("pkg_unhold", 387);
map.insert("pkg_autoremove", 388);
map.insert("pkg_history", 389);
map.insert("apt", 373);
map.insert("brew", 373);
// 390-399: Hardware & Device Info
map.insert("hw_cpu", 390);
map.insert("hw_memory", 391);
map.insert("hw_disk", 392);
map.insert("hw_gpu", 393);
map.insert("hw_network", 394);
map.insert("hw_usb", 395);
map.insert("hw_pci", 396);
map.insert("hw_audio", 397);
map.insert("hw_battery", 398);
map.insert("hw_sensors", 399);
map.insert("lscpu", 390);
map.insert("lsusb", 395);
map.insert("lspci", 396);
// 400-429: GUI Automation
map.insert("gui_list_windows", 400);
map.insert("gui_focus_window", 401);
map.insert("gui_minimize_window", 402);
map.insert("gui_maximize_window", 403);
map.insert("gui_close_window", 404);
map.insert("gui_move_window", 405);
map.insert("gui_resize_window", 406);
map.insert("gui_screenshot", 407);
map.insert("gui_screenshot_window", 408);
map.insert("gui_mouse_move", 409);
map.insert("gui_mouse_click", 410);
map.insert("gui_mouse_double_click", 411);
map.insert("gui_mouse_drag", 412);
map.insert("gui_mouse_scroll", 413);
map.insert("gui_mouse_position", 414);
map.insert("gui_key_press", 415);
map.insert("gui_key_combo", 416);
map.insert("gui_type_text", 417);
map.insert("gui_get_active_window", 418);
map.insert("gui_screen_size", 419);
map.insert("gui_find_image", 420);
map.insert("gui_ocr", 421);
map.insert("gui_notify", 422);
map.insert("gui_dialog_message", 423);
map.insert("gui_dialog_input", 424);
map.insert("gui_dialog_file_open", 425);
map.insert("gui_dialog_file_save", 426);
map.insert("gui_dialog_folder", 427);
map.insert("gui_wait", 428);
map.insert("gui_color_picker", 429);
map.insert("windows", 400);
map.insert("screenshot", 407);
// 430-459: Web Automation
map.insert("web_open_url", 430);
map.insert("web_fetch", 431);
map.insert("web_get", 432);
map.insert("web_post", 433);
map.insert("web_json_get", 434);
map.insert("web_json_post", 435);
map.insert("web_scrape", 436);
map.insert("web_download", 437);
map.insert("web_browser_open", 438);
map.insert("web_parse_url", 439);
map.insert("web_encode_url", 440);
map.insert("web_decode_url", 441);
map.insert("web_parse_query", 442);
map.insert("web_build_query", 443);
map.insert("web_headers", 444);
map.insert("web_cookies", 445);
map.insert("web_form_submit", 446);
map.insert("web_upload_file", 447);
map.insert("web_rest_api", 448);
map.insert("web_websocket", 449);
map.insert("web_graphql", 450);
map.insert("web_check_url", 451);
map.insert("web_robots_txt", 452);
map.insert("web_sitemap", 453);
map.insert("web_extract_emails", 454);
map.insert("web_extract_phones", 455);
map.insert("web_html_to_text", 456);
map.insert("web_html_to_markdown", 457);
map.insert("web_json_path", 458);
map.insert("web_xpath", 459);
map.insert("fetch", 431);
map.insert("scrape", 436);
map.insert("curl", 432);
// 460-479: Clipboard & Input
map.insert("clipboard_get", 460);
map.insert("clipboard_set", 461);
map.insert("clipboard_clear", 462);
map.insert("clipboard_has_text", 463);
map.insert("clipboard_get_image", 464);
map.insert("clipboard_set_image", 465);
map.insert("clipboard_history", 466);
map.insert("input_read_line", 467);
map.insert("input_read_password", 468);
map.insert("input_read_char", 469);
map.insert("input_confirm", 470);
map.insert("input_select", 471);
map.insert("input_multiselect", 472);
map.insert("input_editor", 473);
map.insert("input_autocomplete", 474);
map.insert("input_spinner", 475);
map.insert("input_progress", 476);
map.insert("input_table", 477);
map.insert("input_form", 478);
map.insert("input_date", 479);
map.insert("clipboard", 460);
map.insert("read_line", 467);
map.insert("prompt", 467);
// 480-499: Security & Crypto
map.insert("crypto_hash", 480);
map.insert("crypto_hash_file", 481);
map.insert("crypto_hmac", 482);
map.insert("crypto_random_bytes", 483);
map.insert("crypto_uuid", 484);
map.insert("crypto_base64_encode", 485);
map.insert("crypto_base64_decode", 486);
map.insert("crypto_hex_encode", 487);
map.insert("crypto_hex_decode", 488);
map.insert("crypto_encrypt", 489);
map.insert("crypto_decrypt", 490);
map.insert("crypto_password_hash", 491);
map.insert("crypto_password_verify", 492);
map.insert("crypto_cert_info", 493);
map.insert("crypto_cert_verify", 494);
map.insert("crypto_generate_key", 495);
map.insert("crypto_sign", 496);
map.insert("crypto_verify_signature", 497);
map.insert("crypto_checksum", 498);
map.insert("crypto_jwt_decode", 499);
map.insert("hash", 480);
map.insert("uuid", 484);
map.insert("base64_encode", 485);
map.insert("base64_decode", 486);
map.insert("md5", 1158); // was 480, crypto_hash: md5 answered SHA-256
map.insert("sha256", 1159); // was 480, crypto_hash: md5 answered SHA-256
// 500-519: Database
map.insert("db_sqlite_open", 500);
map.insert("db_sqlite_query", 501);
map.insert("db_sqlite_exec", 502);
map.insert("db_sqlite_tables", 503);
map.insert("db_sqlite_schema", 504);
map.insert("db_sqlite_insert", 505);
map.insert("db_sqlite_update", 506);
map.insert("db_sqlite_delete", 507);
map.insert("db_sqlite_count", 508);
map.insert("db_sqlite_create_table", 509);
map.insert("db_sqlite_drop_table", 510);
map.insert("db_sqlite_backup", 511);
map.insert("db_sqlite_vacuum", 512);
map.insert("db_sqlite_dump", 513);
map.insert("db_sqlite_import", 514);
map.insert("db_csv_to_sqlite", 515);
map.insert("db_sqlite_to_csv", 516);
map.insert("db_json_to_sqlite", 517);
map.insert("db_sqlite_to_json", 518);
map.insert("db_kv_store", 519);
map.insert("sqlite_query", 501);
map.insert("sqlite_exec", 502);
map.insert("sql", 501);
// 520-527: File Editing (for AI agents)
map.insert("file_write", 520);
map.insert("write_file", 520);
map.insert("text_write", 520);
map.insert("file_append", 521);
map.insert("append_file", 521);
map.insert("file_patch", 522);
map.insert("patch_file", 522);
map.insert("file_replace", 523);
map.insert("text_replace", 523);
map.insert("str_replace_in_file", 523);
map.insert("file_insert", 524);
map.insert("insert_lines", 524);
map.insert("file_delete_lines", 525);
map.insert("delete_lines", 525);
map.insert("remove_lines", 525);
map.insert("file_edit", 526);
map.insert("edit_file", 526);
map.insert("text_edit", 526);
map.insert("file_diff", 527);
map.insert("diff_files", 527);
// 528-532: Additional file operations
map.insert("file_backup", 528);
map.insert("backup_file", 528);
map.insert("file_copy", 529);
map.insert("copy_file", 529);
map.insert("cp", 529);
map.insert("file_move", 530);
map.insert("move_file", 530);
map.insert("mv", 530);
map.insert("file_rename", 530);
map.insert("file_exists", 531);
map.insert("path_exists", 531);
map.insert("exists", 531);
map.insert("file_mkdir", 532);
map.insert("mkdir", 532);
map.insert("mkdirp", 532);
// 540-549: A2A (Agent-to-Agent) Protocol
map.insert("a2a_send", 539);
map.insert("a2a_receive", 540);
map.insert("a2a_broadcast", 541);
map.insert("a2a_discover", 542);
map.insert("a2a_register", 543);
map.insert("a2a_unregister", 544);
map.insert("a2a_status", 545);
map.insert("a2a_agents", 546);
// 550-559: NANDA (Consensus) Protocol
map.insert("nanda_propose", 549);
map.insert("nanda_vote", 550);
map.insert("nanda_commit", 551);
map.insert("nanda_abort", 552);
map.insert("nanda_status", 553);
map.insert("nanda_consensus", 554);
map.insert("nanda_quorum", 555);
// AI Coding Assistant - Git (560-586)
map.insert("git_status", 560);
map.insert("git_diff", 561);
map.insert("git_diff_staged", 562);
map.insert("git_log", 563);
map.insert("git_blame", 564);
map.insert("git_branch", 565);
map.insert("git_branches", 566);
map.insert("git_checkout", 567);
map.insert("git_commit", 568);
map.insert("git_add", 569);
map.insert("git_reset", 570);
map.insert("git_stash", 571);
map.insert("git_stash_pop", 572);
map.insert("git_stash_list", 573);
map.insert("git_remote", 574);
map.insert("git_fetch", 575);
map.insert("git_pull", 576);
map.insert("git_push", 577);
map.insert("git_merge", 578);
map.insert("git_rebase", 579);
map.insert("git_cherry_pick", 580);
map.insert("git_tags", 581);
map.insert("git_show", 582);
map.insert("git_rev_parse", 583);
map.insert("git_root", 584);
map.insert("git_ignore", 585);
map.insert("git_clean", 586);
// AI Coding Assistant - Code (590-606)
map.insert("code_parse", 590);
map.insert("code_symbols", 591);
map.insert("code_outline", 592);
map.insert("code_references", 593);
map.insert("code_definition", 594);
map.insert("code_imports", 595);
map.insert("code_exports", 596);
map.insert("code_dependencies", 597);
map.insert("code_callers", 598);
map.insert("code_callees", 599);
map.insert("code_hover", 600);
map.insert("code_signature", 601);
map.insert("code_completions", 602);
map.insert("code_format", 603);
map.insert("code_language", 604);
map.insert("code_comments", 605);
map.insert("code_todos", 606);
// AI Coding Assistant - Project (610-626)
map.insert("project_type", 610);
map.insert("project_root", 611);
map.insert("project_name", 612);
map.insert("project_version", 613);
map.insert("project_dependencies", 614);
map.insert("project_dev_dependencies", 615);
map.insert("project_scripts", 616);
map.insert("project_structure", 617);
map.insert("project_entry_points", 618);
map.insert("project_test_files", 619);
map.insert("project_config_files", 620);
map.insert("project_readme", 621);
map.insert("project_license", 622);
map.insert("project_gitignore", 623);
map.insert("project_languages", 624);
map.insert("project_loc", 625);
map.insert("project_size", 626);
// AI Coding Assistant - Search (630-643)
map.insert("search_code", 630);
map.insert("search_grep", 631);
map.insert("search_regex", 632);
map.insert("search_symbols", 633);
map.insert("search_files", 634);
map.insert("search_recent", 635);
map.insert("search_modified", 636);
map.insert("search_by_type", 637);
map.insert("search_by_size", 638);
map.insert("search_duplicates", 639);
map.insert("search_todos", 640);
map.insert("search_fixmes", 641);
map.insert("search_semantic", 642);
map.insert("search_similar", 643);
// AI Coding Assistant - Test (650-661)
map.insert("test_run", 650);
map.insert("test_run_file", 651);
map.insert("test_run_function", 652);
map.insert("test_list", 653);
map.insert("test_coverage", 654);
map.insert("test_failing", 655);
map.insert("test_passing", 656);
map.insert("test_skipped", 657);
map.insert("test_watch", 658);
map.insert("test_bench", 659);
map.insert("test_snapshot", 660);
map.insert("test_generate", 661);
// AI Coding Assistant - Diagnostics (670-678)
map.insert("diag_check", 670);
map.insert("diag_lint", 671);
map.insert("diag_errors", 672);
map.insert("diag_warnings", 673);
map.insert("diag_all", 674);
map.insert("diag_fix", 675);
map.insert("diag_explain", 676);
map.insert("diag_suppress", 677);
map.insert("diag_config", 678);
// AI Coding Assistant - Refactor (680-691)
map.insert("refactor_rename", 680);
map.insert("refactor_rename_file", 681);
map.insert("refactor_extract_function", 682);
map.insert("refactor_extract_variable", 683);
map.insert("refactor_extract_constant", 684);
map.insert("refactor_inline", 685);
map.insert("refactor_move_to_file", 686);
map.insert("refactor_change_signature", 687);
map.insert("refactor_add_parameter", 688);
map.insert("refactor_remove_parameter", 689);
map.insert("refactor_organize_imports", 690);
map.insert("refactor_remove_unused", 691);
// AI Coding Assistant - Session (700-712)
map.insert("session_start", 700);
map.insert("session_end", 701);
map.insert("session_id", 702);
map.insert("session_history", 703);
map.insert("session_undo", 704);
map.insert("session_redo", 705);
map.insert("session_checkpoint", 706);
map.insert("session_restore", 707);
map.insert("session_checkpoints", 708);
map.insert("session_diff_since", 709);
map.insert("session_changes", 710);
map.insert("session_rollback", 711);
map.insert("session_export", 712);
// AI Coding Assistant - Docs (720-727)
map.insert("docs_generate", 720);
map.insert("docs_search", 721);
map.insert("docs_api", 722);
map.insert("docs_readme", 723);
map.insert("docs_changelog", 724);
map.insert("docs_examples", 725);
map.insert("docs_types", 726);
map.insert("docs_signatures", 727);
// AI Coding Assistant - Environment (730-747)
map.insert("env_detect", 730);
map.insert("env_python", 731);
map.insert("env_node", 732);
map.insert("env_rust", 733);
map.insert("env_go", 734);
map.insert("env_java", 735);
map.insert("env_dotnet", 736);
map.insert("env_ruby", 737);
map.insert("env_shell", 738);
map.insert("env_path", 739);
map.insert("env_var", 740);
map.insert("env_vars", 741);
map.insert("env_set", 742);
map.insert("env_unset", 743);
map.insert("env_docker", 744);
map.insert("env_container", 745);
map.insert("env_venv", 746);
map.insert("env_activate", 747);
// Platform versioning module (indices 748-787)
map.insert("platform_snapshot", 748);
map.insert("platform_os_version", 749);
map.insert("platform_kernel", 750);
map.insert("platform_build", 751);
map.insert("platform_arch", 752);
map.insert("platform_hostname", 753);
map.insert("platform_machine_id", 754);
map.insert("platform_tool_version", 755);
map.insert("platform_tool_versions", 756);
map.insert("platform_detect_tools", 757);
map.insert("platform_has_tool", 758);
map.insert("platform_capabilities", 759);
map.insert("platform_has_admin", 760);
map.insert("platform_has_sudo", 761);
map.insert("platform_has_gui", 762);
map.insert("platform_has_network", 763);
map.insert("platform_has_container", 764);
map.insert("platform_pkg_managers", 765);
map.insert("platform_normalize_path", 766);
map.insert("platform_normalize_output", 767);
map.insert("platform_path_sep", 768);
map.insert("platform_line_ending", 769);
map.insert("platform_shell_type", 770);
map.insert("platform_require", 771);
map.insert("platform_check_reqs", 772);
map.insert("platform_satisfies", 773);
map.insert("platform_diff", 774);
map.insert("platform_compatible", 775);
map.insert("platform_db_init", 776);
map.insert("platform_db_store", 777);
map.insert("platform_db_load", 778);
map.insert("platform_db_list", 779);
map.insert("platform_db_delete", 780);
map.insert("platform_db_compare", 781);
map.insert("platform_db_export", 782);
map.insert("platform_db_import", 783);
map.insert("platform_fingerprint", 784);
map.insert("platform_hash", 785);
// Platform categorized tools (786-799)
map.insert("platform_compilers", 786);
map.insert("platform_build_systems", 787);
map.insert("platform_runtimes", 788);
map.insert("platform_pkg_lang", 789);
map.insert("platform_containers", 790);
map.insert("platform_cloud_clis", 791);
map.insert("platform_databases", 792);
map.insert("platform_linters", 793);
map.insert("platform_vcs", 794);
map.insert("platform_iac_tools", 795);
// Platform library detection (796-803)
map.insert("platform_libs", 796);
map.insert("platform_lib_version", 797);
map.insert("platform_system_libs", 798);
map.insert("platform_sdk_version", 799);
map.insert("platform_libc", 800);
map.insert("platform_libcpp", 801);
map.insert("platform_ssl_version", 802);
map.insert("platform_cuda_version", 803);
// Platform hardware detection (804-815)
map.insert("platform_cpu", 804);
map.insert("platform_cpu_count", 805);
map.insert("platform_cpu_freq", 806);
map.insert("platform_memory", 807);
map.insert("platform_memory_total", 808);
map.insert("platform_memory_free", 809);
map.insert("platform_disks", 810);
map.insert("platform_disk_usage", 811);
map.insert("platform_gpus", 812);
map.insert("platform_gpu_memory", 813);
map.insert("platform_network_interfaces", 814);
map.insert("platform_hardware_summary", 815);
// Proactive monitoring & alerting (816-843)
map.insert("monitor_htop", 816);
map.insert("monitor_iotop", 817);
map.insert("monitor_nmon", 818);
map.insert("monitor_nethogs", 819);
map.insert("monitor_iftop", 820);
map.insert("monitor_glances", 821);
map.insert("monitor_tcpdump", 822);
map.insert("monitor_sockets", 823);
map.insert("monitor_ip_addr", 824);
map.insert("monitor_ip_route", 825);
map.insert("monitor_ip_link", 826);
map.insert("monitor_ethtool", 827);
map.insert("monitor_perf_stat", 828);
map.insert("monitor_perf_record", 829);
map.insert("monitor_memory", 830);
map.insert("monitor_uptime", 831);
map.insert("monitor_users", 832);
map.insert("monitor_logins", 833);
map.insert("monitor_syslog", 834);
map.insert("monitor_dstat", 835);
// Proactive alerting (836-843)
map.insert("monitor_health_check", 836);
map.insert("monitor_alert_create", 837);
map.insert("monitor_alert_list", 838);
map.insert("monitor_alert_history", 839);
map.insert("monitor_alert_remove", 840);
map.insert("monitor_watch_cpu", 841);
map.insert("monitor_watch_memory", 842);
map.insert("monitor_watch_disk", 843);
// Cloud & Scale (844-868)
// Cloud platform
map.insert("cloud_deploy", 844);
map.insert("cloud_instances", 845);
map.insert("cloud_instance_create", 846);
map.insert("cloud_instance_destroy", 847);
map.insert("cloud_instance_status", 848);
map.insert("cloud_instance_connect", 849);
map.insert("cloud_regions", 850);
map.insert("cloud_config", 851);
// Remote REPL
map.insert("repl_serve", 852);
map.insert("repl_connect", 853);
map.insert("repl_sessions", 854);
map.insert("repl_disconnect", 855);
map.insert("repl_broadcast", 856);
// Team workspaces
map.insert("workspace_create", 857);
map.insert("workspace_list", 858);
map.insert("workspace_join", 859);
map.insert("workspace_leave", 860);
map.insert("workspace_members", 861);
map.insert("workspace_share_agent", 862);
map.insert("workspace_agents", 863);
map.insert("workspace_sync", 864);
// Marketplace
map.insert("marketplace_publish", 865);
map.insert("marketplace_search", 866);
map.insert("marketplace_install", 867);
map.insert("marketplace_uninstall", 868);
map.insert("marketplace_list", 869);
map.insert("marketplace_info", 870);
map.insert("marketplace_rate", 871);
map.insert("marketplace_update", 872);
// Telemetry & Analytics
map.insert("telemetry_enable", 873);
map.insert("telemetry_disable", 874);
map.insert("telemetry_status", 875);
map.insert("telemetry_report", 876);
map.insert("telemetry_events", 877);
map.insert("telemetry_reset", 878);
// ── CLI Tool Wrappers (879-987) ──────────────────────────
// SSH (879-883)
map.insert("ssh_exec", 879);
map.insert("ssh_tunnel", 880);
map.insert("ssh_keygen", 881);
map.insert("ssh_copy_id", 882);
map.insert("ssh_config", 883);
// rsync (884)
map.insert("rsync_sync", 884);
map.insert("rsync", 884);
// scp (885-886)
map.insert("scp_upload", 885);
map.insert("scp_download", 886);
// openssl (887-888)
map.insert("openssl_cert_info", 887);
map.insert("openssl_genrsa", 888);
// gpg (889-891)
map.insert("gpg_list_keys", 889);
map.insert("gpg_encrypt", 890);
map.insert("gpg_decrypt", 891);
// Docker (892-911)
map.insert("docker_ps", 892);
map.insert("docker_images", 893);
map.insert("docker_run", 894);
map.insert("docker_exec", 895);
map.insert("docker_logs", 896);
map.insert("docker_stop", 897);
map.insert("docker_rm", 898);
map.insert("docker_build", 899);
map.insert("docker_pull", 900);
map.insert("docker_push", 901);
map.insert("docker_inspect", 902);
map.insert("docker_volumes", 903);
map.insert("docker_networks", 904);
map.insert("docker_compose_up", 905);
map.insert("docker_compose_down", 906);
map.insert("docker_compose_ps", 907);
map.insert("docker_stats", 908);
map.insert("docker_top", 909);
map.insert("docker_cp", 910);
map.insert("docker_tag", 911);
// Podman (912-920)
map.insert("podman_ps", 912);
map.insert("podman_images", 913);
map.insert("podman_run", 914);
map.insert("podman_exec", 915);
map.insert("podman_logs", 916);
map.insert("podman_stop", 917);
map.insert("podman_rm", 918);
map.insert("podman_build", 919);
map.insert("podman_pull", 920);
// Kubernetes / kubectl (921-943)
map.insert("k8s_get", 921);
map.insert("kubectl_get", 921);
map.insert("k8s_apply", 922);
map.insert("kubectl_apply", 922);
map.insert("k8s_delete", 923);
map.insert("kubectl_delete", 923);
map.insert("k8s_describe", 924);
map.insert("kubectl_describe", 924);
map.insert("k8s_logs", 925);
map.insert("kubectl_logs", 925);
map.insert("k8s_exec", 926);
map.insert("kubectl_exec", 926);
map.insert("k8s_pods", 927);
map.insert("k8s_services", 928);
map.insert("k8s_deployments", 929);
map.insert("k8s_namespaces", 930);
map.insert("k8s_nodes", 931);
map.insert("k8s_events", 932);
map.insert("k8s_configmaps", 933);
map.insert("k8s_secrets", 934);
map.insert("k8s_ingresses", 935);
map.insert("k8s_rollout_status", 936);
map.insert("k8s_rollout_restart", 937);
map.insert("k8s_scale", 938);
map.insert("k8s_top_pods", 939);
map.insert("k8s_top_nodes", 940);
map.insert("k8s_context", 941);
map.insert("k8s_contexts", 942);
map.insert("k8s_cluster_info", 943);
// Helm (944-950)
map.insert("helm_list", 944);
map.insert("helm_install", 945);
map.insert("helm_upgrade", 946);
map.insert("helm_uninstall", 947);
map.insert("helm_repos", 948);
map.insert("helm_search", 949);
map.insert("helm_status", 950);
// Terraform (951-958)
map.insert("terraform_init", 951);
map.insert("terraform_plan", 952);
map.insert("terraform_apply", 953);
map.insert("terraform_destroy", 954);
map.insert("terraform_state", 955);
map.insert("terraform_output", 956);
map.insert("terraform_workspace", 957);
map.insert("terraform_validate", 958);
// Ansible (959-962)
map.insert("ansible_playbook", 959);
map.insert("ansible_inventory", 960);
map.insert("ansible_galaxy", 961);
map.insert("ansible_vault", 962);
// journalctl (963)
map.insert("journalctl", 963);
// lsof (964)
map.insert("lsof", 964);
// ── New cross-platform tool implementations (965-987) ────
// wget (965)
map.insert("wget", 965);
map.insert("wget_download", 965);
// age encryption (966-968)
map.insert("age_encrypt", 966);
map.insert("age_decrypt", 967);
map.insert("age_keygen", 968);
// ripgrep (969)
map.insert("rg", 969);
map.insert("rg_search", 969);
map.insert("ripgrep", 969);
// fd (970)
map.insert("fd", 970);
map.insert("fd_find", 970);
// sed (971)
map.insert("sed", 971);
map.insert("sed_replace", 971);
// awk (972)
map.insert("awk", 972);
map.insert("awk_process", 972);
// cut (973)
map.insert("cut", 973);
map.insert("cut_columns", 973);
// xargs (974)
map.insert("xargs", 974);
map.insert("xargs_exec", 974);
// jq (975-976)
map.insert("jq", 975);
map.insert("jq_query", 975);
map.insert("jq_filter", 976);
// yq (977)
map.insert("yq", 977);
map.insert("yq_query", 977);
// pager/less (978)
map.insert("less", 978);
map.insert("pager", 978);
// tee (979)
map.insert("tee", 979);
map.insert("tee_output", 979);
// make (980-981)
map.insert("make_run", 980);
map.insert("make_targets", 981);
// cmake (982-983)
map.insert("cmake_configure", 982);
map.insert("cmake_build", 983);
// ninja (984)
map.insert("ninja_build", 984);
// netstat (985)
map.insert("netstat", 985);
map.insert("netstat_info", 985);
// nmap (986-987)
map.insert("nmap_scan", 986);
map.insert("nmap_quick", 987);
// ============================================================
// CLI Tool Wrappers — Tools 51-150 (indices 988-1103)
// ============================================================
// Dead-code activations (988-994)
map.insert("file_type", 988);
map.insert("dd_copy", 989);
map.insert("lsblk", 990);
map.insert("blkid", 991);
map.insert("strace_cmd", 992);
map.insert("strace", 992);
map.insert("mount_info", 993);
map.insert("top_snapshot", 994);
// tmux (995-998)
map.insert("tmux_new", 995);
map.insert("tmux_list", 996);
map.insert("tmux_attach", 997);
map.insert("tmux_send", 998);
// tmux_kill took reserved slot 556; the tmux range 995-998 is full.
map.insert("tmux_kill", 556);
// screen (999-1001)
map.insert("screen_new", 999);
map.insert("screen_list", 1000);
map.insert("screen_attach", 1001);
// timeout (1002)
map.insert("timeout_cmd", 1002);
map.insert("timeout", 1002);
// pkill (1003)
map.insert("pkill", 1003);
// pgrep (1004)
map.insert("pgrep", 1004);
// lnav (1005)
map.insert("lnav_open", 1005);
map.insert("lnav", 1005);
// multitail (1006)
map.insert("multitail_open", 1006);
map.insert("multitail", 1006);
// logrotate (1007)
map.insert("logrotate_run", 1007);
map.insert("logrotate", 1007);
// ltrace (1008)
map.insert("ltrace_cmd", 1008);
map.insert("ltrace", 1008);
// valgrind (1009-1010)
map.insert("valgrind_run", 1009);
map.insert("valgrind", 1009);
map.insert("valgrind_memcheck", 1010);
// gdb (1011-1012)
map.insert("gdb_run", 1011);
map.insert("gdb", 1011);
map.insert("gdb_bt", 1012);
// lldb (1013)
map.insert("lldb_run", 1013);
map.insert("lldb", 1013);
// objdump (1014-1015)
map.insert("objdump_disasm", 1014);
map.insert("objdump", 1014);
map.insert("objdump_headers", 1015);
// nm (1016)
map.insert("nm_symbols", 1016);
map.insert("nm", 1016);
// readelf (1017-1018)
map.insert("readelf_headers", 1017);
map.insert("readelf", 1017);
map.insert("readelf_sections", 1018);
// strings (1019)
map.insert("strings_extract", 1019);
map.insert("strings", 1019);
// chgrp (1020)
map.insert("chgrp", 1020);
// mtr (1021)
map.insert("mtr_trace", 1021);
map.insert("mtr", 1021);
// host (1022)
map.insert("host_lookup", 1022);
map.insert("host", 1022);
// nc/netcat (1023-1024)
map.insert("nc_connect", 1023);
map.insert("nc", 1023);
map.insert("netcat", 1023);
map.insert("nc_listen", 1024);
// socat (1025)
map.insert("socat_relay", 1025);
map.insert("socat", 1025);
// iperf3 (1026-1027)
map.insert("iperf3_server", 1026);
map.insert("iperf3_client", 1027);
map.insert("iperf3", 1027);
// bat (1028)
map.insert("bat_view", 1028);
map.insert("bat", 1028);
// eza (1029)
map.insert("eza_list", 1029);
map.insert("eza", 1029);
// zoxide (1030-1031)
map.insert("zoxide_add", 1030);
map.insert("zoxide_query", 1031);
map.insert("zoxide", 1031);
// delta (1032)
map.insert("delta_diff", 1032);
map.insert("delta", 1032);
// lazygit (1033)
map.insert("lazygit_open", 1033);
map.insert("lazygit", 1033);
// fzf (1034)
map.insert("fzf_select", 1034);
map.insert("fzf", 1034);
// rga (1035)
map.insert("rga_search", 1035);
map.insert("rga", 1035);
// sd (1036)
map.insert("sd_replace", 1036);
map.insert("sd", 1036);
// hyperfine (1037)
map.insert("hyperfine_bench", 1037);
map.insert("hyperfine", 1037);
// tokei (1038)
map.insert("tokei_count", 1038);
map.insert("tokei", 1038);
// entr (1039)
map.insert("entr_watch", 1039);
map.insert("entr", 1039);
// watchexec (1040)
map.insert("watchexec_run", 1040);
map.insert("watchexec", 1040);
// just (1041-1042)
map.insert("just_run", 1041);
map.insert("just", 1041);
map.insert("just_list", 1042);
// task (1043-1044)
map.insert("task_run", 1043);
map.insert("task_list", 1044);
// direnv (1045-1046)
map.insert("direnv_allow", 1045);
map.insert("direnv_status", 1046);
map.insert("direnv", 1046);
// asdf (1047-1048)
map.insert("asdf_list", 1047);
map.insert("asdf_install", 1048);
map.insert("asdf", 1047);
// mise (1049-1050)
map.insert("mise_use", 1049);
map.insert("mise_list", 1050);
map.insert("mise", 1050);
// uv (1051-1053)
map.insert("uv_run", 1051);
map.insert("uv_pip", 1052);
map.insert("uv_venv", 1053);
// pipx (1054-1055)
map.insert("pipx_install", 1054);
map.insert("pipx_list", 1055);
map.insert("pipx", 1055);
// poetry (1056-1057)
map.insert("poetry_run", 1056);
map.insert("poetry_install", 1057);
// node (1058)
map.insert("node_run", 1058);
// npm (1059-1060)
map.insert("npm_run", 1059);
map.insert("npm_install", 1060);
// pnpm (1061-1062)
map.insert("pnpm_run", 1061);
map.insert("pnpm_install", 1062);
// yarn (1063-1064)
map.insert("yarn_run", 1063);
map.insert("yarn_install", 1064);
// cargo (1065-1067)
map.insert("cargo_run", 1065);
map.insert("cargo_build_cmd", 1066);
map.insert("cargo_test_cmd", 1067);
// rustup (1068-1069)
map.insert("rustup_show", 1068);
map.insert("rustup_update", 1069);
// go (1070-1072)
map.insert("go_run", 1070);
map.insert("go_build", 1071);
map.insert("go_test", 1072);
// mage (1073)
map.insert("mage_run", 1073);
map.insert("mage", 1073);
// bun (1074-1075)
map.insert("bun_run", 1074);
map.insert("bun_install", 1075);
// deno (1076-1077)
map.insert("deno_run", 1076);
map.insert("deno_task", 1077);
// gh (1078-1080)
map.insert("gh_pr", 1078);
map.insert("gh_issue", 1079);
map.insert("gh_repo_cmd", 1080);
map.insert("gh_repo", 1080);
// glab (1081-1082)
map.insert("glab_mr", 1081);
map.insert("glab_issue", 1082);
// act (1083)
map.insert("act_run", 1083);
map.insert("act", 1083);
// pre-commit (1084-1085)
map.insert("pre_commit_run", 1084);
map.insert("pre_commit_install", 1085);
// buildah (1086-1087)
map.insert("buildah_build", 1086);
map.insert("buildah_images", 1087);
// skopeo (1088-1089)
map.insert("skopeo_copy", 1088);
map.insert("skopeo_inspect", 1089);
// trivy (1090-1091)
map.insert("trivy_scan", 1090);
map.insert("trivy_image", 1091);
// hadolint (1092)
map.insert("hadolint_check", 1092);
map.insert("hadolint", 1092);
// shellcheck (1093)
map.insert("shellcheck_check", 1093);
map.insert("shellcheck", 1093);
// shfmt (1094)
map.insert("shfmt_format", 1094);
map.insert("shfmt", 1094);
// yamllint (1095)
map.insert("yamllint_check", 1095);
map.insert("yamllint", 1095);
// prettier (1096)
map.insert("prettier_format", 1096);
map.insert("prettier", 1096);
// eslint (1097)
map.insert("eslint_check", 1097);
map.insert("eslint", 1097);
// ruff (1098-1099)
map.insert("ruff_check", 1098);
map.insert("ruff_format", 1099);
// black (1100)
map.insert("black_format", 1100);
map.insert("black", 1100);
// mypy (1101)
map.insert("mypy_check", 1101);
map.insert("mypy", 1101);
// pytest (1102)
map.insert("pytest_run", 1102);
map.insert("pytest", 1102);
// nodemon (1103)
map.insert("nodemon_run", 1103);
map.insert("nodemon", 1103);
// safety: approval + audit verification (1104-1105)
map.insert("approve", 1104);
map.insert("audit_verify", 1105);
// safety: RBAC principal authorization (1106-1108)
map.insert("rbac_principal", 1106);
map.insert("rbac_grant", 1107);
map.insert("rbac_can", 1108);
map.insert("rbac_register", 1146);
map.insert("rbac_login", 1147);
map.insert("rbac_logout", 1148);
map.insert("rbac_session", 1149);
map.insert("db_csv_query", 1150);
map.insert("db_csv_to_json", 1150);
map.insert("db_json_query", 1151);
map.insert("db_json_to_csv", 1152);
map.insert("clipboard_types", 1153);
map.insert("crypto_random_string", 1154);
map.insert("input_number", 1155);
map.insert("input_timeout", 1156);
// output economy: compact rendering + token estimate (1109-1110)
map.insert("aecon", 1109);
map.insert("tokens", 1110);
// progressive ontology disclosure (1111-1112)
map.insert("ontology_manifest", 1111);
map.insert("ontology_describe", 1112);
// deterministic canonical serialization (1113)
map.insert("canonical", 1113);
// filesystem transactions / checkpoints (1114-1117)
map.insert("tx_begin", 1114);
map.insert("tx_commit", 1115);
map.insert("tx_rollback", 1116);
map.insert("tx_status", 1117);
// output budgeting / paging (1118)
map.insert("budget", 1118);
// plan / apply: atomic, approved, reviewable destructive batches (1119-1120)
map.insert("plan", 1119);
map.insert("plan_diff", 1141);
// Filesystem removal and creation. These have had working, guarded
// implementations the whole time and were never registered, so
// `rm(...)` answered "unknown builtin" while `effect_of("rm")`
// reported Destructive -- the safety layer was guarding a name the
// dispatcher did not have. A shell that cannot delete a file is not
// a product decision.
map.insert("rm", 1142);
map.insert("rmdir", 1143);
map.insert("touch", 1144);
map.insert("cd", 1145);
map.insert("apply", 1120);
// safety introspection (1121)
map.insert("safety_status", 1121);
// stateful sessions: persistent Env across calls (1122-1125)
map.insert("sess_open", 1122);
map.insert("sess_eval", 1123);
map.insert("sess_close", 1124);
map.insert("sess_usage", 1125);
// audit review (1126)
map.insert("audit_tail", 1126);
// output economy: structural digest (1127)
map.insert("digest", 1127);
// output economy: source-side field projection (1128)
map.insert("pick", 1128);
// output economy: AECON decoder — reverses the tabular encoding (1129)
map.insert("aecon_decode", 1129);
// persistent key-value store (sqlite-backed; transactional via db_sqlite_exec)
map.insert("db_kv_get", 1130);
map.insert("db_kv_set", 1131);
map.insert("db_kv_delete", 1132);
map.insert("db_kv_keys", 1133);
map.insert("db_kv_store", 1134);
// transactions: named savepoints (partial rollback)
map.insert("tx_savepoint", 1135);
map.insert("tx_rollback_to", 1136);
// resource governors (§7.6)
map.insert("governor_status", 1137);
map.insert("governor_reset", 1138);
// self-healing: minimal repair context + rollback-bracketed attempts
map.insert("diagnose", 1139);
map.insert("try_repair", 1140);
map
};
/// Runtime feature flags registry
/// Features can be set via IRONSHELL_FEATURES env var (comma-separated) or via feature_enable/disable
static ref FEATURE_FLAGS: std::sync::RwLock<std::collections::HashSet<String>> = {
let mut features = std::collections::HashSet::new();
// Load features from environment variable
if let Ok(env_features) = std::env::var("IRONSHELL_FEATURES") {
for feature in env_features.split(',') {
let feature = feature.trim();
if !feature.is_empty() {
features.insert(feature.to_string());
}
}
}
std::sync::RwLock::new(features)
};
// ===================== Distributed Computing Storage =====================
/// Cluster node representation for distributed computing
static ref CLUSTER_NODES: std::sync::RwLock<HashMap<String, ClusterNode>> = {
std::sync::RwLock::new(HashMap::new())
};
/// Clusters made by cluster_create, keyed by name. In-process only.
static ref CLUSTERS: std::sync::RwLock<BTreeMap<String, String>> = {
std::sync::RwLock::new(BTreeMap::new())
};
/// Distributed jobs registry
static ref DISTRIBUTED_JOBS: std::sync::RwLock<HashMap<String, DistributedJob>> = {
std::sync::RwLock::new(HashMap::new())
};
/// Job results storage
static ref JOB_RESULTS: std::sync::RwLock<HashMap<String, Vec<Value>>> = {
std::sync::RwLock::new(HashMap::new())
};
// ===================== Advanced AI Storage =====================
/// RAG document index - stores document chunks with embeddings
static ref RAG_INDEX: std::sync::RwLock<RagIndex> = {
std::sync::RwLock::new(RagIndex::new())
};
/// Knowledge graph storage
static ref KNOWLEDGE_GRAPH: std::sync::RwLock<KnowledgeGraph> = {
std::sync::RwLock::new(KnowledgeGraph::new())
};
/// Semantic cache for AI responses
static ref SEMANTIC_CACHE: std::sync::RwLock<SemanticCache> = {
std::sync::RwLock::new(SemanticCache::new())
};
// ===================== Enterprise Storage =====================
/// RBAC role definitions
static ref RBAC_ROLES: std::sync::RwLock<HashMap<String, Role>> = {
std::sync::RwLock::new(HashMap::new())
};
/// User to roles mapping
static ref USER_ROLES: std::sync::RwLock<HashMap<String, Vec<String>>> = {
std::sync::RwLock::new(HashMap::new())
};
/// Audit log entries
static ref AUDIT_LOG: std::sync::RwLock<Vec<AuditEntry>> = {
std::sync::RwLock::new(Vec::new())
};
/// SSO configuration
static ref SSO_CONFIG: std::sync::RwLock<Option<SsoConfig>> = {
std::sync::RwLock::new(None)
};
/// SSO active sessions
/// Live SSO tokens, mapped to their user. Nothing inserts into it: `sso_auth`
/// cannot verify a callback, so it mints none (see `bi_sso_auth`).
static ref SSO_SESSIONS: std::sync::RwLock<HashMap<String, String>> = {
std::sync::RwLock::new(HashMap::new())
};
/// Fine-tuning jobs
static ref FINETUNE_JOBS: std::sync::RwLock<HashMap<String, FinetuneJob>> = {
std::sync::RwLock::new(HashMap::new())
};
// ===================== Cloud & Scale Storage =====================
/// Cloud platform configuration
static ref CLOUD_CONFIG: std::sync::RwLock<Option<CloudConfig>> = {
std::sync::RwLock::new(None)
};
/// Remote REPL sessions
static ref REPL_SESSIONS: std::sync::RwLock<HashMap<String, ReplSession>> = {
std::sync::RwLock::new(HashMap::new())
};
/// The REPL listener started by repl_serve.
static ref REPL_SERVER: std::sync::Mutex<Option<ReplServer>> = std::sync::Mutex::new(None);
/// Team workspaces
static ref WORKSPACES: std::sync::RwLock<HashMap<String, TeamWorkspace>> = {
std::sync::RwLock::new(HashMap::new())
};
/// Marketplace package registry (local cache)
static ref MARKETPLACE_PACKAGES: std::sync::RwLock<HashMap<String, MarketplacePackage>> = {
std::sync::RwLock::new(HashMap::new())
};
/// Telemetry events buffer
static ref TELEMETRY_EVENTS: std::sync::RwLock<Vec<TelemetryEvent>> = {
std::sync::RwLock::new(Vec::new())
};
/// Telemetry opt-in state
static ref TELEMETRY_ENABLED: std::sync::RwLock<bool> = {
// Respect IRONSHELL_TELEMETRY env var, default to disabled (opt-in)
let enabled = std::env::var("IRONSHELL_TELEMETRY")
.map(|v| v == "1" || v.to_lowercase() == "true")
.unwrap_or(false);
std::sync::RwLock::new(enabled)
};
}
// ===================== Distributed Computing Types =====================
/// Cluster node representation
#[derive(Debug, Clone)]
struct ClusterNode {
id: String,
address: String,
capabilities: Vec<String>,
status: NodeStatus,
load: f32,
last_heartbeat: std::time::Instant,
}
#[derive(Debug, Clone, PartialEq)]
enum NodeStatus {
/// Added with cluster_add_node. Nothing has checked that the node is up.
Registered,
Online,
Offline,
Busy,
Draining,
}
impl NodeStatus {
fn as_str(&self) -> &'static str {
match self {
NodeStatus::Registered => "registered",
NodeStatus::Online => "online",
NodeStatus::Offline => "offline",
NodeStatus::Busy => "busy",
NodeStatus::Draining => "draining",
}
}
}
/// Distributed job representation
#[derive(Debug, Clone)]
struct DistributedJob {
id: String,
name: String,
task: String,
node_id: Option<String>,
status: JobStatus,
priority: i32,
created_at: std::time::Instant,
started_at: Option<std::time::Instant>,
completed_at: Option<std::time::Instant>,
}
#[derive(Debug, Clone, PartialEq)]
enum JobStatus {
Pending,
Running,
Completed,
Failed(String),
Cancelled,
}
impl JobStatus {
fn as_str(&self) -> &str {
match self {
JobStatus::Pending => "pending",
JobStatus::Running => "running",
JobStatus::Completed => "completed",
JobStatus::Failed(_) => "failed",
JobStatus::Cancelled => "cancelled",
}
}
}
// ===================== Cloud & Scale Types =====================
/// Cloud platform configuration
#[derive(Debug, Clone)]
struct CloudConfig {
provider: String,
region: String,
api_endpoint: Option<String>,
default_instance_type: String,
}
/// Remote REPL session
#[derive(Debug, Clone)]
struct ReplSession {
id: String,
host: String,
port: u16,
user: String,
connected_at: String,
status: String,
workspace_id: Option<String>,
}
/// Team workspace
#[derive(Debug, Clone)]
struct TeamWorkspace {
id: String,
name: String,
owner: String,
members: Vec<WorkspaceMember>,
shared_agents: Vec<String>,
created_at: String,
description: String,
}
/// Workspace member
#[derive(Debug, Clone)]
struct WorkspaceMember {
user: String,
role: String,
joined_at: String,
}
/// Marketplace package
#[derive(Debug, Clone)]
struct MarketplacePackage {
name: String,
version: String,
author: String,
description: String,
category: String,
downloads: i64,
rating: f64,
rating_count: i64,
published_at: String,
installed: bool,
keywords: Vec<String>,
}
/// Telemetry event
#[derive(Debug, Clone)]
struct TelemetryEvent {
id: String,
event_type: String,
category: String,
data: HashMap<String, String>,
timestamp: String,
session_id: String,
}
// ===================== RAG Types =====================
/// RAG document chunk with simple embedding
#[derive(Debug, Clone)]
struct RagChunk {
id: String,
content: String,
source: String,
embedding: Vec<f32>,
metadata: HashMap<String, String>,
}
/// RAG index for retrieval-augmented generation
#[derive(Debug)]
struct RagIndex {
chunks: Vec<RagChunk>,
dimension: usize,
}
impl RagIndex {
fn new() -> Self {
Self {
chunks: Vec::new(),
dimension: 384, // Default embedding dimension
}
}
/// Simple word-frequency based embedding (for demo purposes)
/// In production, use a proper embedding model
fn simple_embed(&self, text: &str) -> Vec<f32> {
let words: Vec<&str> = text.split_whitespace().collect();
let mut embedding = vec![0.0f32; self.dimension];
for (i, word) in words.iter().enumerate() {
let hash = word
.bytes()
.fold(0u32, |acc, b| acc.wrapping_add(b as u32).wrapping_mul(31));
let idx = (hash as usize) % self.dimension;
embedding[idx] += 1.0 / (i + 1) as f32;
}
// Normalize
let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm > 0.0 {
for x in &mut embedding {
*x /= norm;
}
}
embedding
}
/// Cosine similarity between two embeddings
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
if a.len() != b.len() {
return 0.0;
}
let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm_a > 0.0 && norm_b > 0.0 {
dot / (norm_a * norm_b)
} else {
0.0
}
}
fn add_document(
&mut self,
content: &str,
source: &str,
metadata: HashMap<String, String>,
) -> String {
let id = format!("doc_{}", self.chunks.len());
let embedding = self.simple_embed(content);
self.chunks.push(RagChunk {
id: id.clone(),
content: content.to_string(),
source: source.to_string(),
embedding,
metadata,
});
id
}
fn search(&self, query: &str, top_k: usize) -> Vec<(&RagChunk, f32)> {
let query_embedding = self.simple_embed(query);
let mut results: Vec<(&RagChunk, f32)> = self
.chunks
.iter()
.map(|chunk| {
let score = Self::cosine_similarity(&query_embedding, &chunk.embedding);
(chunk, score)
})
.collect();
results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
results.into_iter().take(top_k).collect()
}
fn clear(&mut self) {
self.chunks.clear();
}
}
// ===================== Knowledge Graph Types =====================
/// Entity in the knowledge graph
#[derive(Debug, Clone)]
struct KgEntity {
id: String,
entity_type: String,
name: String,
properties: HashMap<String, String>,
}
/// Relation between entities
#[derive(Debug, Clone)]
struct KgRelation {
id: String,
source_id: String,
target_id: String,
relation_type: String,
properties: HashMap<String, String>,
}
/// Knowledge graph storage
#[derive(Debug)]
struct KnowledgeGraph {
entities: HashMap<String, KgEntity>,
relations: Vec<KgRelation>,
next_entity_id: usize,
next_relation_id: usize,
}
impl KnowledgeGraph {
fn new() -> Self {
Self {
entities: HashMap::new(),
relations: Vec::new(),
next_entity_id: 0,
next_relation_id: 0,
}
}
fn add_entity(
&mut self,
entity_type: &str,
name: &str,
properties: HashMap<String, String>,
) -> String {
let id = format!("e_{}", self.next_entity_id);
self.next_entity_id += 1;
self.entities.insert(
id.clone(),
KgEntity {
id: id.clone(),
entity_type: entity_type.to_string(),
name: name.to_string(),
properties,
},
);
id
}
fn add_relation(
&mut self,
source_id: &str,
target_id: &str,
relation_type: &str,
properties: HashMap<String, String>,
) -> Result<String> {
if !self.entities.contains_key(source_id) {
return Err(crate::safety::not_found(
"kg_relate",
"source entity",
source_id,
));
}
if !self.entities.contains_key(target_id) {
return Err(crate::safety::not_found(
"kg_relate",
"target entity",
target_id,
));
}
let id = format!("r_{}", self.next_relation_id);
self.next_relation_id += 1;
self.relations.push(KgRelation {
id: id.clone(),
source_id: source_id.to_string(),
target_id: target_id.to_string(),
relation_type: relation_type.to_string(),
properties,
});
Ok(id)
}
fn query(&self, pattern: &str) -> Vec<Value> {
let mut results = Vec::new();
let pattern_lower = pattern.to_lowercase();
// Search entities
for entity in self.entities.values() {
if entity.name.to_lowercase().contains(&pattern_lower)
|| entity.entity_type.to_lowercase().contains(&pattern_lower)
{
let mut record = BTreeMap::new();
record.insert("id".to_string(), Value::Str(entity.id.clone()));
record.insert("type".to_string(), Value::Str("entity".to_string()));
record.insert(
"entity_type".to_string(),
Value::Str(entity.entity_type.clone()),
);
record.insert("name".to_string(), Value::Str(entity.name.clone()));
results.push(Value::Record(record));
}
}
// Search relations
for relation in &self.relations {
if relation
.relation_type
.to_lowercase()
.contains(&pattern_lower)
{
let mut record = BTreeMap::new();
record.insert("id".to_string(), Value::Str(relation.id.clone()));
record.insert("type".to_string(), Value::Str("relation".to_string()));
record.insert(
"relation_type".to_string(),
Value::Str(relation.relation_type.clone()),
);
record.insert("source".to_string(), Value::Str(relation.source_id.clone()));
record.insert("target".to_string(), Value::Str(relation.target_id.clone()));
results.push(Value::Record(record));
}
}
results
}
}
// ===================== Semantic Cache Types =====================
/// Cached AI response with embedding
#[derive(Debug, Clone)]
struct CachedResponse {
query: String,
response: String,
embedding: Vec<f32>,
created_at: std::time::Instant,
hit_count: usize,
}
/// Semantic cache for AI responses
#[derive(Debug)]
struct SemanticCache {
entries: Vec<CachedResponse>,
max_entries: usize,
similarity_threshold: f32,
ttl_seconds: u64,
}
impl SemanticCache {
fn new() -> Self {
Self {
entries: Vec::new(),
max_entries: 1000,
similarity_threshold: 0.85,
ttl_seconds: 3600, // 1 hour default TTL
}
}
/// Simple embedding for cache lookups
fn simple_embed(&self, text: &str) -> Vec<f32> {
let dimension = 128;
let words: Vec<&str> = text.split_whitespace().collect();
let mut embedding = vec![0.0f32; dimension];
for (i, word) in words.iter().enumerate() {
let hash = word
.bytes()
.fold(0u32, |acc, b| acc.wrapping_add(b as u32).wrapping_mul(31));
let idx = (hash as usize) % dimension;
embedding[idx] += 1.0 / (i + 1) as f32;
}
let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm > 0.0 {
for x in &mut embedding {
*x /= norm;
}
}
embedding
}
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
if a.len() != b.len() {
return 0.0;
}
let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm_a > 0.0 && norm_b > 0.0 {
dot / (norm_a * norm_b)
} else {
0.0
}
}
fn get(&mut self, query: &str) -> Option<String> {
let query_embedding = self.simple_embed(query);
let now = std::time::Instant::now();
// Clean up expired entries
self.entries
.retain(|e| now.duration_since(e.created_at).as_secs() < self.ttl_seconds);
// Find best match
let mut best_match: Option<(usize, f32)> = None;
for (i, entry) in self.entries.iter().enumerate() {
let similarity = Self::cosine_similarity(&query_embedding, &entry.embedding);
if similarity >= self.similarity_threshold
&& (best_match.is_none() || similarity > best_match.unwrap().1)
{
best_match = Some((i, similarity));
}
}
if let Some((idx, _)) = best_match {
self.entries[idx].hit_count += 1;
return Some(self.entries[idx].response.clone());
}
None
}
fn put(&mut self, query: &str, response: &str) {
let embedding = self.simple_embed(query);
// Evict oldest entries if at capacity
while self.entries.len() >= self.max_entries {
self.entries.remove(0);
}
self.entries.push(CachedResponse {
query: query.to_string(),
response: response.to_string(),
embedding,
created_at: std::time::Instant::now(),
hit_count: 0,
});
}
fn clear(&mut self) {
self.entries.clear();
}
fn stats(&self) -> (usize, usize) {
let total_hits: usize = self.entries.iter().map(|e| e.hit_count).sum();
(self.entries.len(), total_hits)
}
}
// ===================== Enterprise Types =====================
/// RBAC Role definition
#[derive(Debug, Clone)]
struct Role {
name: String,
permissions: Vec<Permission>,
description: String,
created_at: std::time::Instant,
}
/// Permission definition
#[derive(Debug, Clone)]
struct Permission {
resource: String,
actions: Vec<String>,
}
/// Audit log entry
#[derive(Debug, Clone)]
struct AuditEntry {
id: String,
timestamp: std::time::Instant,
user: String,
action: String,
resource: String,
details: String,
severity: AuditSeverity,
success: bool,
}
#[derive(Debug, Clone)]
enum AuditSeverity {
Info,
Warning,
Critical,
}
impl AuditSeverity {
fn as_str(&self) -> &'static str {
match self {
AuditSeverity::Info => "info",
AuditSeverity::Warning => "warning",
AuditSeverity::Critical => "critical",
}
}
}
/// SSO Provider types
#[derive(Debug, Clone)]
enum SsoProvider {
OAuth2,
SAML,
OIDC,
}
impl SsoProvider {
fn as_str(&self) -> &'static str {
match self {
SsoProvider::OAuth2 => "oauth2",
SsoProvider::SAML => "saml",
SsoProvider::OIDC => "oidc",
}
}
fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"oauth2" => Some(SsoProvider::OAuth2),
"saml" => Some(SsoProvider::SAML),
"oidc" => Some(SsoProvider::OIDC),
_ => None,
}
}
}
/// SSO Configuration
#[derive(Debug, Clone)]
struct SsoConfig {
provider: SsoProvider,
client_id: String,
issuer_url: String,
enabled: bool,
}
/// Compliance standards
#[derive(Debug, Clone)]
enum ComplianceStandard {
GDPR,
HIPAA,
SOC2,
PCI,
}
impl ComplianceStandard {
fn from_str(s: &str) -> Option<Self> {
match s.to_uppercase().as_str() {
"GDPR" => Some(ComplianceStandard::GDPR),
"HIPAA" => Some(ComplianceStandard::HIPAA),
"SOC2" => Some(ComplianceStandard::SOC2),
"PCI" => Some(ComplianceStandard::PCI),
_ => None,
}
}
fn as_str(&self) -> &'static str {
match self {
ComplianceStandard::GDPR => "GDPR",
ComplianceStandard::HIPAA => "HIPAA",
ComplianceStandard::SOC2 => "SOC2",
ComplianceStandard::PCI => "PCI",
}
}
}
/// Fine-tuning job
#[derive(Debug, Clone)]
struct FinetuneJob {
id: String,
model: String,
dataset: String,
status: FinetuneStatus,
created_at: std::time::Instant,
config: HashMap<String, String>,
}
/// Fine-tuning status
#[derive(Debug, Clone)]
enum FinetuneStatus {
Pending,
Training,
Completed,
Failed(String),
Cancelled,
}
impl FinetuneStatus {
fn as_str(&self) -> &str {
match self {
FinetuneStatus::Pending => "pending",
FinetuneStatus::Training => "training",
FinetuneStatus::Completed => "completed",
FinetuneStatus::Failed(_) => "failed",
FinetuneStatus::Cancelled => "cancelled",
}
}
}
// Fast dispatch table for builtin functions
static BUILTIN_DISPATCH: &[fn(Vec<Value>, Option<Value>, &mut Env) -> Result<Value>] = &[
// 0-9: General and file system functions
|_, _, _| bi_help(),
|args, input, env| bi_call(args, input, env),
|_, _, _| bi_clear(),
|args, input, _| bi_echo(&args, input),
|args, input, _| bi_print(args, input),
|args, input, _| bi_http_get(args, input),
|args, _, _| bi_some(args),
|_, _, _| bi_none(),
|args, input, _| bi_ls(args, input),
|_, _, _| bi_pwd(),
// 10-19: File operations and data functions
|args, input, _| bi_cat(args, input),
|args, input, _| bi_read_text(args, input),
|args, input, _| bi_head(args, input),
|args, input, _| bi_tail(args, input),
|args, input, _| bi_find(args, input),
|args, input, _| bi_sort(args, input),
|args, input, _| bi_uniq(args, input),
|args, input, _| bi_wc(args, input),
|args, input, _| bi_grep(args, input),
|args, input, env| bi_map(args, input, env),
// 20-29: Pipeline and array functions
|args, input, env| bi_where(args, input, env),
|args, input, env| bi_reduce(args, input, env),
|args, input, _| bi_take(args, input),
|args, input, _| bi_keys(args, input),
|args, input, _| bi_len(args, input),
|args, input, _| bi_type_of(args, input),
|args, input, _| bi_first(args, input),
|args, input, _| bi_last(args, input),
|args, input, env| bi_any(args, input, env),
|args, input, env| bi_all(args, input, env),
// 30-33: MCP functions
|args, input, _| bi_mcp_servers(args, input),
|args, input, _| bi_mcp_detect(args, input),
|args, input, _| bi_mcp_cache_clear(args, input),
|args, input, _| bi_mcp_cache_status(args, input),
// 34-42: String functions
|args, input, _| bi_split(args, input),
|args, input, _| bi_join(args, input),
|args, input, _| bi_trim(args, input),
|args, input, _| bi_upper(args, input),
|args, input, _| bi_lower(args, input),
|args, input, _| bi_replace(args, input),
|args, input, _| bi_contains(args, input),
|args, input, _| bi_starts_with(args, input),
|args, input, _| bi_ends_with(args, input),
// 43-49: Array functions (extended)
|args, input, _| bi_flatten(args, input),
|args, input, _| bi_reverse(args, input),
|args, input, _| bi_slice(args, input),
|args, input, _| bi_range(args, input),
|args, input, _| bi_zip(args, input),
|args, input, _| bi_push(args, input),
|args, input, _| bi_concat(args, input),
// 50-57: Math functions
|args, input, _| bi_abs(args, input),
|args, input, _| bi_min(args, input),
|args, input, _| bi_max(args, input),
|args, input, _| bi_floor(args, input),
|args, input, _| bi_ceil(args, input),
|args, input, _| bi_round(args, input),
|args, input, _| bi_sqrt(args, input),
|args, input, _| bi_pow(args, input),
// 58-64: Utility functions
|args, input, _| bi_exit(args, input),
|args, input, _| bi_env(args, input),
|args, input, _| bi_set_env(args, input),
|args, input, _| bi_sleep(args, input),
|args, input, _| bi_time(args, input),
|args, input, _| bi_json_parse(args, input),
|args, input, _| bi_json_stringify(args, input),
// 65-71: Syntax Knowledge Base functions
|args, input, _| bi_syntax_get(args, input),
|args, input, _| bi_syntax_list(args, input),
|args, input, _| bi_syntax_search(args, input),
|args, input, _| bi_syntax_add(args, input),
|args, input, _| bi_syntax_categories(args, input),
|args, input, _| bi_ab_encode(args, input),
|args, input, _| bi_ab_decode(args, input),
// 72-82: Neural Network functions
|args, input, _| bi_nn_create(args, input),
|args, input, _| bi_nn_forward(args, input),
|args, input, _| bi_nn_mutate(args, input),
|args, input, _| bi_nn_crossover(args, input),
|args, input, _| bi_nn_params(args, input),
|args, input, _| bi_nn_set_params(args, input),
|args, input, _| bi_nn_layers(args, input),
|args, input, _| bi_nn_info(args, input),
|args, input, _| bi_consensus_net(args, input),
|args, input, _| bi_consensus_vote(args, input),
|args, input, _| bi_activation(args, input),
// 83-92: Evolution functions
|args, input, env| bi_population(args, input, env),
|args, input, env| bi_evolve(args, input, env),
|args, input, env| bi_evolve_step(args, input, env),
|args, input, env| bi_fitness(args, input, env),
|args, input, _| bi_best_individual(args, input),
|args, input, _| bi_evolution_stats(args, input),
|args, input, _| bi_selection_strategy(args, input),
|args, input, _| bi_crossover_strategy(args, input),
|args, input, _| bi_evolution_config(args, input),
|args, input, env| bi_coevolve(args, input, env),
// 93-107: Reinforcement Learning functions
|args, input, _| bi_rl_agent(args, input),
|args, input, _| bi_rl_action(args, input),
|args, input, _| bi_rl_update(args, input),
|args, input, _| bi_rl_sarsa_agent(args, input),
|args, input, _| bi_rl_sarsa_update(args, input),
|args, input, _| bi_rl_pg_agent(args, input),
|args, input, _| bi_rl_pg_step(args, input),
|args, input, _| bi_rl_pg_episode_end(args, input),
|args, input, _| bi_rl_ac_agent(args, input),
|args, input, _| bi_rl_ac_update(args, input),
|args, input, _| bi_rl_dqn_agent(args, input),
|args, input, _| bi_rl_dqn_step(args, input),
|args, input, _| bi_rl_replay_buffer(args, input),
|args, input, _| bi_rl_gridworld(args, input),
|args, input, _| bi_rl_env_step(args, input),
// 108-112: OS Tools functions
|args, input, _| bi_tools(args, input),
|args, input, _| bi_tool_info(args, input),
|args, input, _| bi_tool_schema(args, input),
|args, input, _| bi_tool_exec(args, input),
|args, input, _| bi_tool_search(args, input),
// 113-116: Recursive Language Model functions
|args, input, env| bi_rlm_agent(args, input, env),
|args, input, _| bi_rlm_config(args, input),
|args, input, _| bi_rlm_stats(args, input),
|args, input, env| bi_rlm_spawn(args, input, env),
// 117-122: MCP Server/Client functions
|args, input, _| bi_mcp_server(args, input),
|args, input, _| bi_mcp_tools(args, input),
|args, input, _| bi_mcp_call(args, input),
|args, input, _| bi_mcp_resources(args, input),
|args, input, _| bi_mcp_prompts(args, input),
|args, input, _| bi_mcp_connect(args, input),
// 123-131: New aspirational features
|args, input, _| bi_sh(args, input),
|args, input, _| bi_now(args, input),
|args, input, env| bi_sort_by(args, input, env),
|args, input, _| bi_save_json(args, input),
|args, input, _| bi_ai_backends(args, input),
|args, input, _| bi_mcp_server_start(args, input),
|args, input, env| bi_agent_with_mcp(args, input, env),
|args, input, env| bi_each(args, input, env),
|args, input, _| bi_in(args, input),
// 132-136: Additional utility functions
|args, input, _| bi_values(args, input),
|args, input, _| bi_sum(args, input),
|args, input, _| bi_unique(args, input),
|args, input, _| bi_avg(args, input),
|args, input, _| bi_product(args, input),
// 137-142: Configuration functions
|args, input, _| bi_config(args, input),
|args, input, _| bi_config_get(args, input),
|args, input, _| bi_config_set(args, input),
|_, _, _| bi_config_path(),
|args, input, _| bi_config_init(args, input),
|_, _, _| bi_config_reload(),
|_, _, _| bi_themes(),
// 144-150: Plugin functions
|args, input, _| bi_plugins(args, input),
|args, input, _| bi_plugin_info(args, input),
|args, input, _| bi_plugin_enable(args, input),
|args, input, _| bi_plugin_disable(args, input),
|args, input, _| bi_plugin_load(args, input),
|args, input, _| bi_plugin_unload(args, input),
|_, _, _| bi_plugin_categories(),
// 151: Error handling functions
|args, input, _| bi_is_error(args, input),
// 152-156: Debugging tools
|args, input, _| bi_debug(args, input),
|args, input, _| bi_assert(args, input),
|args, input, _| bi_trace(args, input),
|args, input, _| bi_type_assert(args, input),
|args, input, _| bi_inspect(args, input),
// 157-163: Platform detection functions
|_, _, _| bi_platform(),
|_, _, _| bi_is_windows(),
|_, _, _| bi_is_linux(),
|_, _, _| bi_is_macos(),
|_, _, _| bi_is_unix(),
|_, _, _| bi_is_bsd(),
|args, input, _| bi_platform_module(args, input),
// 164-168: Feature flag functions
|_, _, _| bi_features(),
|args, input, _| bi_feature_enabled(args, input),
|args, input, _| bi_feature_enable(args, input),
|args, input, _| bi_feature_disable(args, input),
|args, input, _| bi_feature_set(args, input),
// 169-180: Distributed Computing functions
|args, input, _| bi_cluster_create(args, input),
|args, input, _| bi_cluster_add_node(args, input),
|args, input, _| bi_cluster_remove_node(args, input),
|args, input, _| bi_cluster_status(args, input),
|args, input, _| bi_cluster_nodes(args, input),
|args, input, _| bi_job_submit(args, input),
|args, input, _| bi_job_status(args, input),
|args, input, _| bi_job_cancel(args, input),
|args, input, _| bi_job_results(args, input),
|args, input, _| bi_job_list(args, input),
|args, input, _| bi_remote_exec(args, input),
|args, input, _| bi_aggregate_results(args, input),
// 181-192: Advanced AI functions
|args, input, _| bi_rag_index(args, input),
|args, input, _| bi_rag_search(args, input),
|args, input, _| bi_rag_query(args, input),
|args, input, _| bi_rag_clear(args, input),
|args, input, _| bi_kg_add(args, input),
|args, input, _| bi_kg_relate(args, input),
|args, input, _| bi_kg_query(args, input),
|args, input, _| bi_kg_entities(args, input),
|args, input, _| bi_kg_relations(args, input),
|args, input, _| bi_semantic_cache(args, input),
|args, input, _| bi_semantic_cache_get(args, input),
|args, input, _| bi_semantic_cache_clear(args, input),
// 193-199: RBAC functions
|args, input, _| bi_role_create(args, input),
|args, input, _| bi_role_delete(args, input),
|args, input, _| bi_role_grant(args, input),
|args, input, _| bi_role_revoke(args, input),
|args, input, _| bi_user_roles(args, input),
|args, input, _| bi_check_permission(args, input),
|args, input, _| bi_roles_list(args, input),
// 200-203: Audit functions
|args, input, _| bi_audit_log(args, input),
|args, input, _| bi_audit_query(args, input),
|args, input, _| bi_audit_export(args, input),
|args, input, _| bi_audit_stats(args, input),
// 204-208: SSO functions
|args, input, _| bi_sso_init(args, input),
|args, input, _| bi_sso_auth(args, input),
|args, input, _| bi_sso_validate(args, input),
|args, input, _| bi_sso_logout(args, input),
|args, input, _| bi_sso_status(args, input),
// 209-210: Compliance functions
|args, input, _| bi_compliance_check(args, input),
|args, input, _| bi_compliance_report(args, input),
// 211-214: Fine-tuning functions
|args, input, _| bi_finetune_start(args, input),
|args, input, _| bi_finetune_status(args, input),
|args, input, _| bi_finetune_list(args, input),
|args, input, _| bi_finetune_cancel(args, input),
// 215-229: A2UI (Agent-to-User Interface) functions
|args, input, _| bi_a2ui_notify(args, input),
|args, input, _| bi_a2ui_prompt(args, input),
|args, input, _| bi_a2ui_confirm(args, input),
|args, input, _| bi_a2ui_select(args, input),
|args, input, _| bi_a2ui_progress(args, input),
|args, input, _| bi_a2ui_progress_complete(args, input),
|args, input, _| bi_a2ui_render(args, input),
|args, input, _| bi_a2ui_render_table(args, input),
|args, input, _| bi_a2ui_render_code(args, input),
|args, input, _| bi_a2ui_toast(args, input),
|args, input, _| bi_a2ui_status(args, input),
|args, input, _| bi_a2ui_clear(args, input),
|args, input, _| bi_a2ui_agent_started(args, input),
|args, input, _| bi_a2ui_agent_completed(args, input),
|args, input, _| bi_a2ui_agent_thinking(args, input),
// 230-249: Process Management
|args, input, _| bi_proc_list(args, input),
|args, input, _| bi_proc_kill(args, input),
|args, input, _| bi_proc_info(args, input),
|args, input, _| bi_proc_spawn(args, input),
|args, input, _| bi_proc_wait(args, input),
|args, input, _| bi_proc_exists(args, input),
|args, input, _| bi_proc_children(args, input),
|args, input, _| bi_proc_parent(args, input),
|args, input, _| bi_proc_priority(args, input),
|args, input, _| bi_proc_set_priority(args, input),
|args, input, _| bi_proc_cpu_usage(args, input),
|args, input, _| bi_proc_mem_usage(args, input),
|args, input, _| bi_proc_threads(args, input),
|args, input, _| bi_proc_env(args, input),
|args, input, _| bi_proc_cwd(args, input),
|args, input, _| bi_proc_exe(args, input),
|args, input, _| bi_proc_cmdline(args, input),
|args, input, _| bi_proc_start_time(args, input),
|args, input, _| bi_proc_suspend(args, input),
|args, input, _| bi_proc_resume(args, input),
// 250-269: Filesystem Extended
|args, input, _| bi_fs_stat(args, input),
|args, input, _| bi_fs_lstat(args, input),
|args, input, _| bi_fs_chmod(args, input),
|args, input, _| bi_fs_chown(args, input),
|args, input, _| bi_fs_link(args, input),
|args, input, _| bi_fs_symlink(args, input),
|args, input, _| bi_fs_readlink(args, input),
|args, input, _| bi_fs_realpath(args, input),
|args, input, _| bi_fs_tempfile(args, input),
|args, input, _| bi_fs_tempdir(args, input),
|args, input, _| bi_fs_watch(args, input),
|args, input, _| bi_fs_unwatch(args, input),
|args, input, _| bi_fs_glob(args, input),
|args, input, _| bi_fs_walk(args, input),
|args, input, _| bi_fs_tree(args, input),
|args, input, _| bi_fs_du(args, input),
|args, input, _| bi_fs_df(args, input),
|args, input, _| bi_fs_mount(args, input),
|args, input, _| bi_fs_unmount(args, input),
|args, input, _| bi_fs_mounts(args, input),
// 270-289: Network Operations
|args, input, _| bi_net_interfaces(args, input),
|args, input, _| bi_net_ip(args, input),
|args, input, _| bi_net_dns_lookup(args, input),
|args, input, _| bi_net_dns_reverse(args, input),
|args, input, _| bi_net_ping(args, input),
|args, input, _| bi_net_traceroute(args, input),
|args, input, _| bi_net_connections(args, input),
|args, input, _| bi_net_listen(args, input),
|args, input, _| bi_net_connect(args, input),
|args, input, _| bi_net_send(args, input),
|args, input, _| bi_net_recv(args, input),
|args, input, _| bi_net_close(args, input),
|args, input, _| bi_net_ports(args, input),
|args, input, _| bi_net_arp(args, input),
|args, input, _| bi_net_route(args, input),
|args, input, _| bi_net_stats(args, input),
|args, input, _| bi_net_bandwidth(args, input),
|args, input, _| bi_net_latency(args, input),
|args, input, _| bi_net_scan(args, input),
|args, input, _| bi_net_whois(args, input),
// 290-309: System Information
|args, input, _| bi_sys_info(args, input),
|args, input, _| bi_sys_hostname(args, input),
|args, input, _| bi_sys_os(args, input),
|args, input, _| bi_sys_arch(args, input),
|args, input, _| bi_sys_kernel(args, input),
|args, input, _| bi_sys_uptime(args, input),
|args, input, _| bi_sys_boot_time(args, input),
|args, input, _| bi_sys_cpu_info(args, input),
|args, input, _| bi_sys_cpu_count(args, input),
|args, input, _| bi_sys_cpu_freq(args, input),
|args, input, _| bi_sys_mem_info(args, input),
|args, input, _| bi_sys_swap_info(args, input),
|args, input, _| bi_sys_disk_info(args, input),
|args, input, _| bi_sys_load_avg(args, input),
|args, input, _| bi_sys_users(args, input),
|args, input, _| bi_sys_groups(args, input),
|args, input, _| bi_sys_user_info(args, input),
|args, input, _| bi_sys_env_all(args, input),
|args, input, _| bi_sys_locale(args, input),
|args, input, _| bi_sys_timezone(args, input),
// 310-329: Service & Daemon Management
|args, input, _| bi_svc_list(args, input),
|args, input, _| bi_svc_status(args, input),
|args, input, _| bi_svc_start(args, input),
|args, input, _| bi_svc_stop(args, input),
|args, input, _| bi_svc_restart(args, input),
|args, input, _| bi_svc_enable(args, input),
|args, input, _| bi_svc_disable(args, input),
|args, input, _| bi_svc_logs(args, input),
|args, input, _| bi_svc_info(args, input),
|args, input, _| bi_svc_create(args, input),
|args, input, _| bi_svc_delete(args, input),
|args, input, _| bi_cron_list(args, input),
|args, input, _| bi_cron_add(args, input),
|args, input, _| bi_cron_remove(args, input),
|args, input, _| bi_cron_enable(args, input),
|args, input, _| bi_cron_disable(args, input),
|args, input, _| bi_at_schedule(args, input),
|args, input, _| bi_at_list(args, input),
|args, input, _| bi_at_remove(args, input),
|args, input, _| bi_startup_list(args, input),
// 330-349: Archive & Compression
|args, input, _| bi_zip_create(args, input),
|args, input, _| bi_zip_extract(args, input),
|args, input, _| bi_zip_list(args, input),
|args, input, _| bi_zip_add(args, input),
|args, input, _| bi_tar_create(args, input),
|args, input, _| bi_tar_extract(args, input),
|args, input, _| bi_tar_list(args, input),
|args, input, _| bi_gzip_compress(args, input),
|args, input, _| bi_gzip_decompress(args, input),
|args, input, _| bi_bzip2_compress(args, input),
|args, input, _| bi_bzip2_decompress(args, input),
|args, input, _| bi_xz_compress(args, input),
|args, input, _| bi_xz_decompress(args, input),
|args, input, _| bi_zstd_compress(args, input),
|args, input, _| bi_zstd_decompress(args, input),
|args, input, _| bi_archive_info(args, input),
|args, input, _| bi_archive_test(args, input),
// 347-349: reserved slots. No name is registered at these indices, so
// dispatch never reaches them; they keep the numbering of the entries after.
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
// 350-369: User & Permission Management
|args, input, _| bi_user_list(args, input),
|args, input, _| bi_user_add(args, input),
|args, input, _| bi_user_del(args, input),
|args, input, _| bi_user_mod(args, input),
|args, input, _| bi_user_passwd(args, input),
|args, input, _| bi_user_lock(args, input),
|args, input, _| bi_user_unlock(args, input),
|args, input, _| bi_group_list(args, input),
|args, input, _| bi_group_add(args, input),
|args, input, _| bi_group_del(args, input),
|args, input, _| bi_group_mod(args, input),
|args, input, _| bi_group_members(args, input),
|args, input, _| bi_perm_get(args, input),
|args, input, _| bi_perm_set(args, input),
|args, input, _| bi_perm_check(args, input),
|args, input, _| bi_acl_get(args, input),
|args, input, _| bi_acl_set(args, input),
|args, input, _| bi_sudo_check(args, input),
|args, input, _| bi_sudo_exec(args, input),
|args, input, _| bi_capabilities(args, input),
// 370-389: Package Management
|args, input, _| bi_pkg_list(args, input),
|args, input, _| bi_pkg_search(args, input),
|args, input, _| bi_pkg_info(args, input),
|args, input, _| bi_pkg_install(args, input),
|args, input, _| bi_pkg_remove(args, input),
|args, input, _| bi_pkg_update(args, input),
|args, input, _| bi_pkg_upgrade(args, input),
|args, input, _| bi_pkg_clean(args, input),
|args, input, _| bi_pkg_sources(args, input),
|args, input, _| bi_pkg_add_source(args, input),
|args, input, _| bi_pkg_files(args, input),
|args, input, _| bi_pkg_owner(args, input),
|args, input, _| bi_pkg_verify(args, input),
|args, input, _| bi_pkg_deps(args, input),
|args, input, _| bi_pkg_rdeps(args, input),
|args, input, _| bi_pkg_changelog(args, input),
|args, input, _| bi_pkg_hold(args, input),
|args, input, _| bi_pkg_unhold(args, input),
|args, input, _| bi_pkg_autoremove(args, input),
|args, input, _| bi_pkg_history(args, input),
// 390-399: Hardware & Device Info
|args, input, _| bi_hw_cpu(args, input),
|args, input, _| bi_hw_memory(args, input),
|args, input, _| bi_hw_disk(args, input),
|args, input, _| bi_hw_gpu(args, input),
|args, input, _| bi_hw_network(args, input),
|args, input, _| bi_hw_usb(args, input),
|args, input, _| bi_hw_pci(args, input),
|args, input, _| bi_hw_audio(args, input),
|args, input, _| bi_hw_battery(args, input),
|args, input, _| bi_hw_sensors(args, input),
// 400-429: GUI Automation
|args, input, _| bi_gui_list_windows(args, input),
|args, input, _| bi_gui_focus_window(args, input),
|args, input, _| bi_gui_minimize_window(args, input),
|args, input, _| bi_gui_maximize_window(args, input),
|args, input, _| bi_gui_close_window(args, input),
|args, input, _| bi_gui_move_window(args, input),
|args, input, _| bi_gui_resize_window(args, input),
|args, input, _| bi_gui_screenshot(args, input),
|args, input, _| bi_gui_screenshot_window(args, input),
|args, input, _| bi_gui_mouse_move(args, input),
|args, input, _| bi_gui_mouse_click(args, input),
|args, input, _| bi_gui_mouse_double_click(args, input),
|args, input, _| bi_gui_mouse_drag(args, input),
|args, input, _| bi_gui_mouse_scroll(args, input),
|args, input, _| bi_gui_mouse_position(args, input),
|args, input, _| bi_gui_key_press(args, input),
|args, input, _| bi_gui_key_combo(args, input),
|args, input, _| bi_gui_type_text(args, input),
|args, input, _| bi_gui_get_active_window(args, input),
|args, input, _| bi_gui_screen_size(args, input),
|args, input, _| bi_gui_find_image(args, input),
|args, input, _| bi_gui_ocr(args, input),
|args, input, _| bi_gui_notify(args, input),
|args, input, _| bi_gui_dialog_message(args, input),
|args, input, _| bi_gui_dialog_input(args, input),
|args, input, _| bi_gui_dialog_file_open(args, input),
|args, input, _| bi_gui_dialog_file_save(args, input),
|args, input, _| bi_gui_dialog_folder(args, input),
|args, input, _| bi_gui_wait(args, input),
|args, input, _| bi_gui_color_picker(args, input),
// 430-459: Web Automation
|args, input, _| bi_web_open_url(args, input),
|args, input, _| bi_web_fetch(args, input),
|args, input, _| bi_web_get(args, input),
|args, input, _| bi_web_post(args, input),
|args, input, _| bi_web_json_get(args, input),
|args, input, _| bi_web_json_post(args, input),
|args, input, _| bi_web_scrape(args, input),
|args, input, _| bi_web_download(args, input),
|args, input, _| bi_web_browser_open(args, input),
|args, input, _| bi_web_parse_url(args, input),
|args, input, _| bi_web_encode_url(args, input),
|args, input, _| bi_web_decode_url(args, input),
|args, input, _| bi_web_parse_query(args, input),
|args, input, _| bi_web_build_query(args, input),
|args, input, _| bi_web_headers(args, input),
|args, input, _| bi_web_cookies(args, input),
|args, input, _| bi_web_form_submit(args, input),
|args, input, _| bi_web_upload_file(args, input),
|args, input, _| bi_web_rest_api(args, input),
|args, input, _| bi_web_websocket(args, input),
|args, input, _| bi_web_graphql(args, input),
|args, input, _| bi_web_check_url(args, input),
|args, input, _| bi_web_robots_txt(args, input),
|args, input, _| bi_web_sitemap(args, input),
|args, input, _| bi_web_extract_emails(args, input),
|args, input, _| bi_web_extract_phones(args, input),
|args, input, _| bi_web_html_to_text(args, input),
|args, input, _| bi_web_html_to_markdown(args, input),
|args, input, _| bi_web_json_path(args, input),
|args, input, _| bi_web_xpath(args, input),
// 460-479: Clipboard & Input
|args, input, _| bi_clipboard_get(args, input),
|args, input, _| bi_clipboard_set(args, input),
|args, input, _| bi_clipboard_clear(args, input),
|args, input, _| bi_clipboard_has_text(args, input),
|args, input, _| bi_clipboard_get_image(args, input),
|args, input, _| bi_clipboard_set_image(args, input),
|args, input, _| bi_clipboard_history(args, input),
|args, input, _| bi_input_read_line(args, input),
|args, input, _| bi_input_read_password(args, input),
|args, input, _| bi_input_read_char(args, input),
|args, input, _| bi_input_confirm(args, input),
|args, input, _| bi_input_select(args, input),
|args, input, _| bi_input_multiselect(args, input),
|args, input, _| bi_input_editor(args, input),
|args, input, _| bi_input_autocomplete(args, input),
|args, input, _| bi_input_spinner(args, input),
|args, input, _| bi_input_progress(args, input),
|args, input, _| bi_input_table(args, input),
|args, input, _| bi_input_form(args, input),
|args, input, _| bi_input_date(args, input),
// 480-499: Security & Crypto
|args, input, _| bi_crypto_hash(args, input),
|args, input, _| bi_crypto_hash_file(args, input),
|args, input, _| bi_crypto_hmac(args, input),
|args, input, _| bi_crypto_random_bytes(args, input),
|args, input, _| bi_crypto_uuid(args, input),
|args, input, _| bi_crypto_base64_encode(args, input),
|args, input, _| bi_crypto_base64_decode(args, input),
|args, input, _| bi_crypto_hex_encode(args, input),
|args, input, _| bi_crypto_hex_decode(args, input),
|args, input, _| bi_crypto_encrypt(args, input),
|args, input, _| bi_crypto_decrypt(args, input),
|args, input, _| bi_crypto_password_hash(args, input),
|args, input, _| bi_crypto_password_verify(args, input),
|args, input, _| bi_crypto_cert_info(args, input),
|args, input, _| bi_crypto_cert_verify(args, input),
|args, input, _| bi_crypto_generate_key(args, input),
|args, input, _| bi_crypto_sign(args, input),
|args, input, _| bi_crypto_verify_signature(args, input),
|args, input, _| bi_crypto_checksum(args, input),
|args, input, _| bi_crypto_jwt_decode(args, input),
// 500-519: Database
|args, input, _| bi_db_sqlite_open(args, input),
|args, input, _| bi_db_sqlite_query(args, input),
|args, input, _| bi_db_sqlite_exec(args, input),
|args, input, _| bi_db_sqlite_tables(args, input),
|args, input, _| bi_db_sqlite_schema(args, input),
|args, input, _| bi_db_sqlite_insert(args, input),
|args, input, _| bi_db_sqlite_update(args, input),
|args, input, _| bi_db_sqlite_delete(args, input),
|args, input, _| bi_db_sqlite_count(args, input),
|args, input, _| bi_db_sqlite_create_table(args, input),
|args, input, _| bi_db_sqlite_drop_table(args, input),
|args, input, _| bi_db_sqlite_backup(args, input),
|args, input, _| bi_db_sqlite_vacuum(args, input),
|args, input, _| bi_db_sqlite_dump(args, input),
|args, input, _| bi_db_sqlite_import(args, input),
|args, input, _| bi_db_csv_to_sqlite(args, input),
|args, input, _| bi_db_sqlite_to_csv(args, input),
|args, input, _| bi_db_json_to_sqlite(args, input),
|args, input, _| bi_db_sqlite_to_json(args, input),
|args, input, _| bi_db_kv_store(args, input),
// 520-527: File Editing (for AI agents)
|args, input, _| bi_file_write(args, input),
|args, input, _| bi_file_append(args, input),
|args, input, _| bi_file_patch(args, input),
|args, input, _| bi_file_replace(args, input),
|args, input, _| bi_file_insert(args, input),
|args, input, _| bi_file_delete_lines(args, input),
|args, input, _| bi_file_edit(args, input),
|args, input, _| bi_file_diff(args, input),
// 528-532: Additional file operations
|args, input, _| bi_file_backup(args, input),
|args, input, _| bi_file_copy(args, input),
|args, input, _| bi_file_move(args, input),
|args, input, _| bi_file_exists(args, input),
// 532: `mkdir` / `mkdirp` / `file_mkdir`.
//
// This slot was a placeholder, and the three names were registered into it —
// the comment below said the reserved range began at 533 when the first
// placeholder was at 532, an off-by-one that put an advertised builtin on a
// stub. `mkdir` returned `Ok(Value::Null)` for every input and created
// nothing, while `bi_file_mkdir` sat in the file fully written and never
// referenced.
//
// Nothing caught it. `catalog_reachability` asks whether an advertised name
// dispatches, and this one did — to `|_, _, _| Ok(Value::Null)`. A silent
// no-op is worse than a missing builtin, because the caller is handed a
// success value.
|args, input, _| bi_file_mkdir(args, input),
// 533-539: Reserved/placeholder
|_, _, _| Ok(Value::Null),
|_, _, _| Ok(Value::Null),
|_, _, _| Ok(Value::Null),
|_, _, _| Ok(Value::Null),
|_, _, _| Ok(Value::Null),
|_, _, _| Ok(Value::Null),
// 540-547: A2A (Agent-to-Agent) Protocol
|args, input, _| bi_a2a_send(args, input),
|args, input, _| bi_a2a_receive(args, input),
|args, input, _| bi_a2a_broadcast(args, input),
|args, input, _| bi_a2a_discover(args, input),
|args, input, _| bi_a2a_register(args, input),
|args, input, _| bi_a2a_unregister(args, input),
|args, input, _| bi_a2a_status(args, input),
|args, input, _| bi_a2a_agents(args, input),
// 548-549: Reserved
|_, _, _| Ok(Value::Null),
|_, _, _| Ok(Value::Null),
// 550-556: NANDA (Consensus) Protocol
|args, input, _| bi_nanda_propose(args, input),
|args, input, _| bi_nanda_vote(args, input),
|args, input, _| bi_nanda_commit(args, input),
|args, input, _| bi_nanda_abort(args, input),
|args, input, _| bi_nanda_status(args, input),
|args, input, _| bi_nanda_consensus(args, input),
|args, input, _| bi_nanda_quorum(args, input),
// 556: tmux_kill (a reserved slot). Reserved (557-559)
|args, input, _| bi_tmux_kill(args, input), // 556
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
// AI Coding Assistant - Git (560-586)
|args, input, _| bi_git_status(args, input),
|args, input, _| bi_git_diff(args, input),
|args, input, _| bi_git_diff_staged(args, input),
|args, input, _| bi_git_log(args, input),
|args, input, _| bi_git_blame(args, input),
|args, input, _| bi_git_branch(args, input),
|args, input, _| bi_git_branches(args, input),
|args, input, _| bi_git_checkout(args, input),
|args, input, _| bi_git_commit(args, input),
|args, input, _| bi_git_add(args, input),
|args, input, _| bi_git_reset(args, input),
|args, input, _| bi_git_stash(args, input),
|args, input, _| bi_git_stash_pop(args, input),
|args, input, _| bi_git_stash_list(args, input),
|args, input, _| bi_git_remote(args, input),
|args, input, _| bi_git_fetch(args, input),
|args, input, _| bi_git_pull(args, input),
|args, input, _| bi_git_push(args, input),
|args, input, _| bi_git_merge(args, input),
|args, input, _| bi_git_rebase(args, input),
|args, input, _| bi_git_cherry_pick(args, input),
|args, input, _| bi_git_tags(args, input),
|args, input, _| bi_git_show(args, input),
|args, input, _| bi_git_rev_parse(args, input),
|args, input, _| bi_git_root(args, input),
|args, input, _| bi_git_ignore(args, input),
|args, input, _| bi_git_clean(args, input),
// Reserved (587-589)
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
// AI Coding Assistant - Code (590-606)
|args, input, _| bi_code_parse(args, input),
|args, input, _| bi_code_symbols(args, input),
|args, input, _| bi_code_outline(args, input),
|args, input, _| bi_code_references(args, input),
|args, input, _| bi_code_definition(args, input),
|args, input, _| bi_code_imports(args, input),
|args, input, _| bi_code_exports(args, input),
|args, input, _| bi_code_dependencies(args, input),
|args, input, _| bi_code_callers(args, input),
|args, input, _| bi_code_callees(args, input),
|args, input, _| bi_code_hover(args, input),
|args, input, _| bi_code_signature(args, input),
|args, input, _| bi_code_completions(args, input),
|args, input, _| bi_code_format(args, input),
|args, input, _| bi_code_language(args, input),
|args, input, _| bi_code_comments(args, input),
|args, input, _| bi_code_todos(args, input),
// Reserved (607-609)
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
// AI Coding Assistant - Project (610-626)
|args, input, _| bi_project_type(args, input),
|args, input, _| bi_project_root(args, input),
|args, input, _| bi_project_name(args, input),
|args, input, _| bi_project_version(args, input),
|args, input, _| bi_project_dependencies(args, input),
|args, input, _| bi_project_dev_dependencies(args, input),
|args, input, _| bi_project_scripts(args, input),
|args, input, _| bi_project_structure(args, input),
|args, input, _| bi_project_entry_points(args, input),
|args, input, _| bi_project_test_files(args, input),
|args, input, _| bi_project_config_files(args, input),
|args, input, _| bi_project_readme(args, input),
|args, input, _| bi_project_license(args, input),
|args, input, _| bi_project_gitignore(args, input),
|args, input, _| bi_project_languages(args, input),
|args, input, _| bi_project_loc(args, input),
|args, input, _| bi_project_size(args, input),
// Reserved (627-629)
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
// AI Coding Assistant - Search (630-643)
|args, input, _| bi_search_code(args, input),
|args, input, _| bi_search_grep(args, input),
|args, input, _| bi_search_regex(args, input),
|args, input, _| bi_search_symbols(args, input),
|args, input, _| bi_search_files(args, input),
|args, input, _| bi_search_recent(args, input),
|args, input, _| bi_search_modified(args, input),
|args, input, _| bi_search_by_type(args, input),
|args, input, _| bi_search_by_size(args, input),
|args, input, _| bi_search_duplicates(args, input),
|args, input, _| bi_search_todos(args, input),
|args, input, _| bi_search_fixmes(args, input),
|args, input, _| bi_search_semantic(args, input),
|args, input, _| bi_search_similar(args, input),
// Reserved (644-649)
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
// AI Coding Assistant - Test (650-661)
|args, input, _| bi_test_run(args, input),
|args, input, _| bi_test_run_file(args, input),
|args, input, _| bi_test_run_function(args, input),
|args, input, _| bi_test_list(args, input),
|args, input, _| bi_test_coverage(args, input),
|args, input, _| bi_test_failing(args, input),
|args, input, _| bi_test_passing(args, input),
|args, input, _| bi_test_skipped(args, input),
|args, input, _| bi_test_watch(args, input),
|args, input, _| bi_test_bench(args, input),
|args, input, _| bi_test_snapshot(args, input),
|args, input, _| bi_test_generate(args, input),
// Reserved (662-669)
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
// AI Coding Assistant - Diagnostics (670-678)
|args, input, _| bi_diag_check(args, input),
|args, input, _| bi_diag_lint(args, input),
|args, input, _| bi_diag_errors(args, input),
|args, input, _| bi_diag_warnings(args, input),
|args, input, _| bi_diag_all(args, input),
|args, input, _| bi_diag_fix(args, input),
|args, input, _| bi_diag_explain(args, input),
|args, input, _| bi_diag_suppress(args, input),
|args, input, _| bi_diag_config(args, input),
// Reserved (679)
|_, _, _| Err(reserved_slot()),
// AI Coding Assistant - Refactor (680-691)
|args, input, _| bi_refactor_rename(args, input),
|args, input, _| bi_refactor_rename_file(args, input),
|args, input, _| bi_refactor_extract_function(args, input),
|args, input, _| bi_refactor_extract_variable(args, input),
|args, input, _| bi_refactor_extract_constant(args, input),
|args, input, _| bi_refactor_inline(args, input),
|args, input, _| bi_refactor_move_to_file(args, input),
|args, input, _| bi_refactor_change_signature(args, input),
|args, input, _| bi_refactor_add_parameter(args, input),
|args, input, _| bi_refactor_remove_parameter(args, input),
|args, input, _| bi_refactor_organize_imports(args, input),
|args, input, _| bi_refactor_remove_unused(args, input),
// Reserved (692-699)
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
// AI Coding Assistant - Session (700-712)
|args, input, _| bi_session_start(args, input),
|args, input, _| bi_session_end(args, input),
|args, input, _| bi_session_id(args, input),
|args, input, _| bi_session_history(args, input),
|args, input, _| bi_session_undo(args, input),
|args, input, _| bi_session_redo(args, input),
|args, input, _| bi_session_checkpoint(args, input),
|args, input, _| bi_session_restore(args, input),
|args, input, _| bi_session_checkpoints(args, input),
|args, input, _| bi_session_diff_since(args, input),
|args, input, _| bi_session_changes(args, input),
|args, input, _| bi_session_rollback(args, input),
|args, input, _| bi_session_export(args, input),
// Reserved (713-719)
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
// AI Coding Assistant - Docs (720-727)
|args, input, _| bi_docs_generate(args, input),
|args, input, _| bi_docs_search(args, input),
|args, input, _| bi_docs_api(args, input),
|args, input, _| bi_docs_readme(args, input),
|args, input, _| bi_docs_changelog(args, input),
|args, input, _| bi_docs_examples(args, input),
|args, input, _| bi_docs_types(args, input),
|args, input, _| bi_docs_signatures(args, input),
// Reserved (728-729)
|_, _, _| Err(reserved_slot()),
|_, _, _| Err(reserved_slot()),
// AI Coding Assistant - Environment (730-747)
|args, input, _| bi_env_detect(args, input),
|args, input, _| bi_env_python(args, input),
|args, input, _| bi_env_node(args, input),
|args, input, _| bi_env_rust(args, input),
|args, input, _| bi_env_go(args, input),
|args, input, _| bi_env_java(args, input),
|args, input, _| bi_env_dotnet(args, input),
|args, input, _| bi_env_ruby(args, input),
|args, input, _| bi_env_shell(args, input),
|args, input, _| bi_env_path(args, input),
|args, input, _| bi_env_var(args, input),
|args, input, _| bi_env_vars(args, input),
|args, input, _| bi_env_set(args, input),
|args, input, _| bi_env_unset(args, input),
|args, input, _| bi_env_docker(args, input),
|args, input, _| bi_env_container(args, input),
|args, input, _| bi_env_venv(args, input),
|args, input, _| bi_env_activate(args, input),
// Platform versioning module (748-785)
|args, input, _| bi_platform_snapshot(args, input),
|args, input, _| bi_platform_os_version(args, input),
|args, input, _| bi_platform_kernel(args, input),
|args, input, _| bi_platform_build(args, input),
|args, input, _| bi_platform_arch(args, input),
|args, input, _| bi_platform_hostname(args, input),
|args, input, _| bi_platform_machine_id(args, input),
|args, input, _| bi_platform_tool_version(args, input),
|args, input, _| bi_platform_tool_versions(args, input),
|args, input, _| bi_platform_detect_tools(args, input),
|args, input, _| bi_platform_has_tool(args, input),
|args, input, _| bi_platform_capabilities(args, input),
|args, input, _| bi_platform_has_admin(args, input),
|args, input, _| bi_platform_has_sudo(args, input),
|args, input, _| bi_platform_has_gui(args, input),
|args, input, _| bi_platform_has_network(args, input),
|args, input, _| bi_platform_has_container(args, input),
|args, input, _| bi_platform_pkg_managers(args, input),
|args, input, _| bi_platform_normalize_path(args, input),
|args, input, _| bi_platform_normalize_output(args, input),
|args, input, _| bi_platform_path_sep(args, input),
|args, input, _| bi_platform_line_ending(args, input),
|args, input, _| bi_platform_shell_type(args, input),
|args, input, _| bi_platform_require(args, input),
|args, input, _| bi_platform_check_reqs(args, input),
|args, input, _| bi_platform_satisfies(args, input),
|args, input, _| bi_platform_diff(args, input),
|args, input, _| bi_platform_compatible(args, input),
|args, input, _| bi_platform_db_init(args, input),
|args, input, _| bi_platform_db_store(args, input),
|args, input, _| bi_platform_db_load(args, input),
|args, input, _| bi_platform_db_list(args, input),
|args, input, _| bi_platform_db_delete(args, input),
|args, input, _| bi_platform_db_compare(args, input),
|args, input, _| bi_platform_db_export(args, input),
|args, input, _| bi_platform_db_import(args, input),
|args, input, _| bi_platform_fingerprint(args, input),
|args, input, _| bi_platform_hash(args, input),
// Platform categorized tools (786-795)
|args, input, _| bi_platform_compilers(args, input),
|args, input, _| bi_platform_build_systems(args, input),
|args, input, _| bi_platform_runtimes(args, input),
|args, input, _| bi_platform_pkg_lang(args, input),
|args, input, _| bi_platform_containers(args, input),
|args, input, _| bi_platform_cloud_clis(args, input),
|args, input, _| bi_platform_databases(args, input),
|args, input, _| bi_platform_linters(args, input),
|args, input, _| bi_platform_vcs(args, input),
|args, input, _| bi_platform_iac_tools(args, input),
// Platform library detection (796-803)
|args, input, _| bi_platform_libs(args, input),
|args, input, _| bi_platform_lib_version(args, input),
|args, input, _| bi_platform_system_libs(args, input),
|args, input, _| bi_platform_sdk_version(args, input),
|args, input, _| bi_platform_libc(args, input),
|args, input, _| bi_platform_libcpp(args, input),
|args, input, _| bi_platform_ssl_version(args, input),
|args, input, _| bi_platform_cuda_version(args, input),
// Platform hardware detection (804-815)
|args, input, _| bi_platform_cpu(args, input),
|args, input, _| bi_platform_cpu_count(args, input),
|args, input, _| bi_platform_cpu_freq(args, input),
|args, input, _| bi_platform_memory(args, input),
|args, input, _| bi_platform_memory_total(args, input),
|args, input, _| bi_platform_memory_free(args, input),
|args, input, _| bi_platform_disks(args, input),
|args, input, _| bi_platform_disk_usage(args, input),
|args, input, _| bi_platform_gpus(args, input),
|args, input, _| bi_platform_gpu_memory(args, input),
|args, input, _| bi_platform_network_interfaces(args, input),
|args, input, _| bi_platform_hardware_summary(args, input),
// Proactive monitoring & alerting (816-835)
|args, input, _| bi_htop_snapshot(args, input),
|args, input, _| bi_iotop_snapshot(args, input),
|args, input, _| bi_nmon_snapshot(args, input),
|args, input, _| bi_nethogs_info(args, input),
|args, input, _| bi_iftop_info(args, input),
|args, input, _| bi_glances_info(args, input),
|args, input, _| bi_tcpdump_capture(args, input),
|args, input, _| bi_ss_info(args, input),
|args, input, _| bi_ip_addr(args, input),
|args, input, _| bi_ip_route(args, input),
|args, input, _| bi_ip_link(args, input),
|args, input, _| bi_ethtool_info(args, input),
|args, input, _| bi_perf_stat(args, input),
|args, input, _| bi_perf_record(args, input),
|args, input, _| bi_free_mem(args, input),
|args, input, _| bi_uptime_extended(args, input),
|args, input, _| bi_who_users(args, input),
|args, input, _| bi_last_logins(args, input),
|args, input, _| bi_syslog_search(args, input),
|args, input, _| bi_dstat_info(args, input),
// Proactive alerting (836-843)
|args, input, _| bi_health_check(args, input),
|args, input, _| bi_alert_create(args, input),
|args, input, _| bi_alert_list(args, input),
|args, input, _| bi_alert_history(args, input),
|args, input, _| bi_alert_remove(args, input),
|args, input, _| bi_watch_cpu(args, input),
|args, input, _| bi_watch_memory(args, input),
|args, input, _| bi_watch_disk(args, input),
// Cloud & Scale (844-878)
// Cloud platform (844-851)
|args, input, _| bi_cloud_deploy(args, input), // 844
|args, input, _| bi_cloud_instances(args, input), // 845
|args, input, _| bi_cloud_instance_create(args, input), // 846
|args, input, _| bi_cloud_instance_destroy(args, input), // 847
|args, input, _| bi_cloud_instance_status(args, input), // 848
|args, input, _| bi_cloud_instance_connect(args, input), // 849
|args, input, _| bi_cloud_regions(args, input), // 850
|args, input, _| bi_cloud_config(args, input), // 851
// Remote REPL (852-856)
|args, input, _| bi_repl_serve(args, input), // 852
|args, input, _| bi_repl_connect(args, input), // 853
|args, input, _| bi_repl_sessions(args, input), // 854
|args, input, _| bi_repl_disconnect(args, input), // 855
|args, input, _| bi_repl_broadcast(args, input), // 856
// Team workspaces (857-864)
|args, input, _| bi_workspace_create(args, input), // 857
|args, input, _| bi_workspace_list(args, input), // 858
|args, input, _| bi_workspace_join(args, input), // 859
|args, input, _| bi_workspace_leave(args, input), // 860
|args, input, _| bi_workspace_members(args, input), // 861
|args, input, _| bi_workspace_share_agent(args, input), // 862
|args, input, _| bi_workspace_agents(args, input), // 863
|args, input, _| bi_workspace_sync(args, input), // 864
// Marketplace (865-872)
|args, input, _| bi_marketplace_publish(args, input), // 865
|args, input, _| bi_marketplace_search(args, input), // 866
|args, input, _| bi_marketplace_install(args, input), // 867
|args, input, _| bi_marketplace_uninstall(args, input), // 868
|args, input, _| bi_marketplace_list(args, input), // 869
|args, input, _| bi_marketplace_info(args, input), // 870
|args, input, _| bi_marketplace_rate(args, input), // 871
|args, input, _| bi_marketplace_update(args, input), // 872
// Telemetry (873-878)
|args, input, _| bi_telemetry_enable(args, input), // 873
|args, input, _| bi_telemetry_disable(args, input), // 874
|args, input, _| bi_telemetry_status(args, input), // 875
|args, input, _| bi_telemetry_report(args, input), // 876
|args, input, _| bi_telemetry_events(args, input), // 877
|args, input, _| bi_telemetry_reset(args, input), // 878
// ── CLI Tool Wrappers (879-987) ──────────────────────────
// SSH (879-883)
|args, input, _| bi_ssh_exec(args, input), // 879
|args, input, _| bi_ssh_tunnel(args, input), // 880
|args, input, _| bi_ssh_keygen(args, input), // 881
|args, input, _| bi_ssh_copy_id(args, input), // 882
|args, input, _| bi_ssh_config(args, input), // 883
// rsync (884)
|args, input, _| bi_rsync_sync(args, input), // 884
// scp (885-886)
|args, input, _| bi_scp_upload(args, input), // 885
|args, input, _| bi_scp_download(args, input), // 886
// openssl (887-888)
|args, input, _| bi_openssl_cert_info(args, input), // 887
|args, input, _| bi_openssl_genrsa(args, input), // 888
// gpg (889-891)
|args, input, _| bi_gpg_list_keys(args, input), // 889
|args, input, _| bi_gpg_encrypt(args, input), // 890
|args, input, _| bi_gpg_decrypt(args, input), // 891
// Docker (892-911)
|args, input, _| bi_docker_ps(args, input), // 892
|args, input, _| bi_docker_images(args, input), // 893
|args, input, _| bi_docker_run(args, input), // 894
|args, input, _| bi_docker_exec(args, input), // 895
|args, input, _| bi_docker_logs(args, input), // 896
|args, input, _| bi_docker_stop(args, input), // 897
|args, input, _| bi_docker_rm(args, input), // 898
|args, input, _| bi_docker_build(args, input), // 899
|args, input, _| bi_docker_pull(args, input), // 900
|args, input, _| bi_docker_push(args, input), // 901
|args, input, _| bi_docker_inspect(args, input), // 902
|args, input, _| bi_docker_volumes(args, input), // 903
|args, input, _| bi_docker_networks(args, input), // 904
|args, input, _| bi_docker_compose_up(args, input), // 905
|args, input, _| bi_docker_compose_down(args, input), // 906
|args, input, _| bi_docker_compose_ps(args, input), // 907
|args, input, _| bi_docker_stats(args, input), // 908
|args, input, _| bi_docker_top(args, input), // 909
|args, input, _| bi_docker_cp(args, input), // 910
|args, input, _| bi_docker_tag(args, input), // 911
// Podman (912-920)
|args, input, _| bi_podman_ps(args, input), // 912
|args, input, _| bi_podman_images(args, input), // 913
|args, input, _| bi_podman_run(args, input), // 914
|args, input, _| bi_podman_exec(args, input), // 915
|args, input, _| bi_podman_logs(args, input), // 916
|args, input, _| bi_podman_stop(args, input), // 917
|args, input, _| bi_podman_rm(args, input), // 918
|args, input, _| bi_podman_build(args, input), // 919
|args, input, _| bi_podman_pull(args, input), // 920
// Kubernetes / kubectl (921-943)
|args, input, _| bi_k8s_get(args, input), // 921
|args, input, _| bi_k8s_apply(args, input), // 922
|args, input, _| bi_k8s_delete(args, input), // 923
|args, input, _| bi_k8s_describe(args, input), // 924
|args, input, _| bi_k8s_logs(args, input), // 925
|args, input, _| bi_k8s_exec(args, input), // 926
|args, input, _| bi_k8s_pods(args, input), // 927
|args, input, _| bi_k8s_services(args, input), // 928
|args, input, _| bi_k8s_deployments(args, input), // 929
|args, input, _| bi_k8s_namespaces(args, input), // 930
|args, input, _| bi_k8s_nodes(args, input), // 931
|args, input, _| bi_k8s_events(args, input), // 932
|args, input, _| bi_k8s_configmaps(args, input), // 933
|args, input, _| bi_k8s_secrets(args, input), // 934
|args, input, _| bi_k8s_ingresses(args, input), // 935
|args, input, _| bi_k8s_rollout_status(args, input), // 936
|args, input, _| bi_k8s_rollout_restart(args, input), // 937
|args, input, _| bi_k8s_scale(args, input), // 938
|args, input, _| bi_k8s_top_pods(args, input), // 939
|args, input, _| bi_k8s_top_nodes(args, input), // 940
|args, input, _| bi_k8s_context(args, input), // 941
|args, input, _| bi_k8s_contexts(args, input), // 942
|args, input, _| bi_k8s_cluster_info(args, input), // 943
// Helm (944-950)
|args, input, _| bi_helm_list(args, input), // 944
|args, input, _| bi_helm_install(args, input), // 945
|args, input, _| bi_helm_upgrade(args, input), // 946
|args, input, _| bi_helm_uninstall(args, input), // 947
|args, input, _| bi_helm_repos(args, input), // 948
|args, input, _| bi_helm_search(args, input), // 949
|args, input, _| bi_helm_status(args, input), // 950
// Terraform (951-958)
|args, input, _| bi_terraform_init(args, input), // 951
|args, input, _| bi_terraform_plan(args, input), // 952
|args, input, _| bi_terraform_apply(args, input), // 953
|args, input, _| bi_terraform_destroy(args, input), // 954
|args, input, _| bi_terraform_state(args, input), // 955
|args, input, _| bi_terraform_output(args, input), // 956
|args, input, _| bi_terraform_workspace(args, input), // 957
|args, input, _| bi_terraform_validate(args, input), // 958
// Ansible (959-962)
|args, input, _| bi_ansible_playbook(args, input), // 959
|args, input, _| bi_ansible_inventory(args, input), // 960
|args, input, _| bi_ansible_galaxy(args, input), // 961
|args, input, _| bi_ansible_vault(args, input), // 962
// journalctl (963)
|args, input, _| bi_journalctl(args, input), // 963
// lsof (964)
|args, input, _| bi_lsof(args, input), // 964
// ── New cross-platform tool implementations (965-987) ────
// wget (965)
|args, input, _| bi_wget_download(args, input), // 965
// age encryption (966-968)
|args, input, _| bi_age_encrypt(args, input), // 966
|args, input, _| bi_age_decrypt(args, input), // 967
|args, input, _| bi_age_keygen(args, input), // 968
// ripgrep (969)
|args, input, _| bi_rg_search(args, input), // 969
// fd (970)
|args, input, _| bi_fd_find(args, input), // 970
// sed (971)
|args, input, _| bi_sed_replace(args, input), // 971
// awk (972)
|args, input, _| bi_awk_process(args, input), // 972
// cut (973)
|args, input, _| bi_cut_columns(args, input), // 973
// xargs (974)
|args, input, _| bi_xargs_exec(args, input), // 974
// jq (975-976)
|args, input, _| bi_jq_query(args, input), // 975
|args, input, _| bi_jq_filter(args, input), // 976
// yq (977)
|args, input, _| bi_yq_query(args, input), // 977
// pager/less (978)
|args, input, _| bi_pager(args, input), // 978
// tee (979)
|args, input, _| bi_tee_output(args, input), // 979
// make (980-981)
|args, input, _| bi_make_run(args, input), // 980
|args, input, _| bi_make_targets(args, input), // 981
// cmake (982-983)
|args, input, _| bi_cmake_configure(args, input), // 982
|args, input, _| bi_cmake_build(args, input), // 983
// ninja (984)
|args, input, _| bi_ninja_build(args, input), // 984
// netstat (985)
|args, input, _| bi_netstat_info(args, input), // 985
// nmap (986-987)
|args, input, _| bi_nmap_scan(args, input), // 986
|args, input, _| bi_nmap_quick(args, input), // 987
// ============================================================
// CLI Tool Wrappers — Tools 51-150 (indices 988-1103)
// ============================================================
// Dead-code activations (988-994)
|args, input, _| bi_file_type(args, input), // 988
|args, input, _| bi_dd_copy(args, input), // 989
|args, input, _| bi_lsblk(args, input), // 990
|args, input, _| bi_blkid(args, input), // 991
|args, input, _| bi_strace_cmd(args, input), // 992
|args, input, _| bi_mount_info(args, input), // 993
|args, input, _| bi_top_snapshot(args, input), // 994
// tmux (995-998)
|args, input, _| bi_tmux_new(args, input), // 995
|args, input, _| bi_tmux_list(args, input), // 996
|args, input, _| bi_tmux_attach(args, input), // 997
|args, input, _| bi_tmux_send(args, input), // 998
// screen (999-1001)
|args, input, _| bi_screen_new(args, input), // 999
|args, input, _| bi_screen_list(args, input), // 1000
|args, input, _| bi_screen_attach(args, input), // 1001
// timeout (1002)
|args, input, _| bi_timeout_cmd(args, input), // 1002
// pkill (1003)
|args, input, _| bi_pkill(args, input), // 1003
// pgrep (1004)
|args, input, _| bi_pgrep(args, input), // 1004
// lnav (1005)
|args, input, _| bi_lnav_open(args, input), // 1005
// multitail (1006)
|args, input, _| bi_multitail_open(args, input), // 1006
// logrotate (1007)
|args, input, _| bi_logrotate_run(args, input), // 1007
// ltrace (1008)
|args, input, _| bi_ltrace_cmd(args, input), // 1008
// valgrind (1009-1010)
|args, input, _| bi_valgrind_run(args, input), // 1009
|args, input, _| bi_valgrind_memcheck(args, input), // 1010
// gdb (1011-1012)
|args, input, _| bi_gdb_run(args, input), // 1011
|args, input, _| bi_gdb_bt(args, input), // 1012
// lldb (1013)
|args, input, _| bi_lldb_run(args, input), // 1013
// objdump (1014-1015)
|args, input, _| bi_objdump_disasm(args, input), // 1014
|args, input, _| bi_objdump_headers(args, input), // 1015
// nm (1016)
|args, input, _| bi_nm_symbols(args, input), // 1016
// readelf (1017-1018)
|args, input, _| bi_readelf_headers(args, input), // 1017
|args, input, _| bi_readelf_sections(args, input), // 1018
// strings (1019)
|args, input, _| bi_strings_extract(args, input), // 1019
// chgrp (1020)
|args, input, _| bi_chgrp(args, input), // 1020
// mtr (1021)
|args, input, _| bi_mtr_trace(args, input), // 1021
// host (1022)
|args, input, _| bi_host_lookup(args, input), // 1022
// nc/netcat (1023-1024)
|args, input, _| bi_nc_connect(args, input), // 1023
|args, input, _| bi_nc_listen(args, input), // 1024
// socat (1025)
|args, input, _| bi_socat_relay(args, input), // 1025
// iperf3 (1026-1027)
|args, input, _| bi_iperf3_server(args, input), // 1026
|args, input, _| bi_iperf3_client(args, input), // 1027
// bat (1028)
|args, input, _| bi_bat_view(args, input), // 1028
// eza (1029)
|args, input, _| bi_eza_list(args, input), // 1029
// zoxide (1030-1031)
|args, input, _| bi_zoxide_add(args, input), // 1030
|args, input, _| bi_zoxide_query(args, input), // 1031
// delta (1032)
|args, input, _| bi_delta_diff(args, input), // 1032
// lazygit (1033)
|args, input, _| bi_lazygit_open(args, input), // 1033
// fzf (1034)
|args, input, _| bi_fzf_select(args, input), // 1034
// rga (1035)
|args, input, _| bi_rga_search(args, input), // 1035
// sd (1036)
|args, input, _| bi_sd_replace(args, input), // 1036
// hyperfine (1037)
|args, input, _| bi_hyperfine_bench(args, input), // 1037
// tokei (1038)
|args, input, _| bi_tokei_count(args, input), // 1038
// entr (1039)
|args, input, _| bi_entr_watch(args, input), // 1039
// watchexec (1040)
|args, input, _| bi_watchexec_run(args, input), // 1040
// just (1041-1042)
|args, input, _| bi_just_run(args, input), // 1041
|args, input, _| bi_just_list(args, input), // 1042
// task (1043-1044)
|args, input, _| bi_task_run(args, input), // 1043
|args, input, _| bi_task_list(args, input), // 1044
// direnv (1045-1046)
|args, input, _| bi_direnv_allow(args, input), // 1045
|args, input, _| bi_direnv_status(args, input), // 1046
// asdf (1047-1048)
|args, input, _| bi_asdf_list(args, input), // 1047
|args, input, _| bi_asdf_install(args, input), // 1048
// mise (1049-1050)
|args, input, _| bi_mise_use(args, input), // 1049
|args, input, _| bi_mise_list(args, input), // 1050
// uv (1051-1053)
|args, input, _| bi_uv_run(args, input), // 1051
|args, input, _| bi_uv_pip(args, input), // 1052
|args, input, _| bi_uv_venv(args, input), // 1053
// pipx (1054-1055)
|args, input, _| bi_pipx_install(args, input), // 1054
|args, input, _| bi_pipx_list(args, input), // 1055
// poetry (1056-1057)
|args, input, _| bi_poetry_run(args, input), // 1056
|args, input, _| bi_poetry_install(args, input), // 1057
// node (1058)
|args, input, _| bi_node_run(args, input), // 1058
// npm (1059-1060)
|args, input, _| bi_npm_run(args, input), // 1059
|args, input, _| bi_npm_install(args, input), // 1060
// pnpm (1061-1062)
|args, input, _| bi_pnpm_run(args, input), // 1061
|args, input, _| bi_pnpm_install(args, input), // 1062
// yarn (1063-1064)
|args, input, _| bi_yarn_run(args, input), // 1063
|args, input, _| bi_yarn_install(args, input), // 1064
// cargo (1065-1067)
|args, input, _| bi_cargo_run(args, input), // 1065
|args, input, _| bi_cargo_build_cmd(args, input), // 1066
|args, input, _| bi_cargo_test_cmd(args, input), // 1067
// rustup (1068-1069)
|args, input, _| bi_rustup_show(args, input), // 1068
|args, input, _| bi_rustup_update(args, input), // 1069
// go (1070-1072)
|args, input, _| bi_go_run(args, input), // 1070
|args, input, _| bi_go_build(args, input), // 1071
|args, input, _| bi_go_test(args, input), // 1072
// mage (1073)
|args, input, _| bi_mage_run(args, input), // 1073
// bun (1074-1075)
|args, input, _| bi_bun_run(args, input), // 1074
|args, input, _| bi_bun_install(args, input), // 1075
// deno (1076-1077)
|args, input, _| bi_deno_run(args, input), // 1076
|args, input, _| bi_deno_task(args, input), // 1077
// gh (1078-1080)
|args, input, _| bi_gh_pr(args, input), // 1078
|args, input, _| bi_gh_issue(args, input), // 1079
|args, input, _| bi_gh_repo_cmd(args, input), // 1080
// glab (1081-1082)
|args, input, _| bi_glab_mr(args, input), // 1081
|args, input, _| bi_glab_issue(args, input), // 1082
// act (1083)
|args, input, _| bi_act_run(args, input), // 1083
// pre-commit (1084-1085)
|args, input, _| bi_pre_commit_run(args, input), // 1084
|args, input, _| bi_pre_commit_install(args, input), // 1085
// buildah (1086-1087)
|args, input, _| bi_buildah_build(args, input), // 1086
|args, input, _| bi_buildah_images(args, input), // 1087
// skopeo (1088-1089)
|args, input, _| bi_skopeo_copy(args, input), // 1088
|args, input, _| bi_skopeo_inspect(args, input), // 1089
// trivy (1090-1091)
|args, input, _| bi_trivy_scan(args, input), // 1090
|args, input, _| bi_trivy_image(args, input), // 1091
// hadolint (1092)
|args, input, _| bi_hadolint_check(args, input), // 1092
// shellcheck (1093)
|args, input, _| bi_shellcheck_check(args, input), // 1093
// shfmt (1094)
|args, input, _| bi_shfmt_format(args, input), // 1094
// yamllint (1095)
|args, input, _| bi_yamllint_check(args, input), // 1095
// prettier (1096)
|args, input, _| bi_prettier_format(args, input), // 1096
// eslint (1097)
|args, input, _| bi_eslint_check(args, input), // 1097
// ruff (1098-1099)
|args, input, _| bi_ruff_check(args, input), // 1098
|args, input, _| bi_ruff_format(args, input), // 1099
// black (1100)
|args, input, _| bi_black_format(args, input), // 1100
// mypy (1101)
|args, input, _| bi_mypy_check(args, input), // 1101
// pytest (1102)
|args, input, _| bi_pytest_run(args, input), // 1102
// nodemon (1103)
|args, input, _| bi_nodemon_run(args, input), // 1103
// safety: approval + audit verification (1104-1105)
|args, input, _| bi_approve(args, input), // 1104
|args, input, _| bi_audit_verify(args, input), // 1105
// safety: RBAC principal authorization (1106-1108)
|args, input, _| bi_rbac_principal(args, input), // 1106
|args, input, _| bi_rbac_grant(args, input), // 1107
|args, input, _| bi_rbac_can(args, input), // 1108
// output economy: compact rendering + token estimate (1109-1110)
|args, input, _| bi_aecon(args, input), // 1109
|args, input, _| bi_tokens(args, input), // 1110
// progressive ontology disclosure (1111-1112)
|args, input, _| bi_ontology_manifest(args, input), // 1111
|args, input, _| bi_ontology_describe(args, input), // 1112
// deterministic canonical serialization (1113)
|args, input, _| bi_canonical(args, input), // 1113
// filesystem transactions / checkpoints (1114-1117)
|args, input, _| bi_tx_begin(args, input), // 1114
|args, input, _| bi_tx_commit(args, input), // 1115
|args, input, _| bi_tx_rollback(args, input), // 1116
|args, input, _| bi_tx_status(args, input), // 1117
// output budgeting / paging (1118)
|args, input, _| bi_budget(args, input), // 1118
// plan / apply (1119-1120)
|args, input, _| bi_plan(args, input), // 1119
|args, input, _| bi_apply(args, input), // 1120
// safety introspection (1121)
|args, input, _| bi_safety_status(args, input), // 1121
// stateful sessions (1122-1125)
|args, input, _| bi_sess_open(args, input), // 1122
|args, input, _| bi_sess_eval(args, input), // 1123
|args, input, _| bi_sess_close(args, input), // 1124
|args, input, _| bi_sess_usage(args, input), // 1125
// audit review (1126)
|args, input, _| bi_audit_tail(args, input), // 1126
// output economy: structural digest (1127)
|args, input, _| bi_digest(args, input), // 1127
// output economy: source-side field projection (1128)
|args, input, _| bi_pick(args, input), // 1128
// output economy: AECON decoder — reverses the tabular encoding (1129)
|args, input, _| bi_aecon_decode(args, input), // 1129
// persistent key-value store (sqlite-backed; transactional via db_sqlite_exec)
|args, input, _| bi_db_kv_get(args, input), // 1130
|args, input, _| bi_db_kv_set(args, input), // 1131
|args, input, _| bi_db_kv_delete(args, input), // 1132
|args, input, _| bi_db_kv_keys(args, input), // 1133
|args, input, _| bi_db_kv_store(args, input), // 1134
// transactions: named savepoints (partial rollback)
|args, input, _| bi_tx_savepoint(args, input), // 1135
|args, input, _| bi_tx_rollback_to(args, input), // 1136
// resource governors (§7.6)
|args, input, _| bi_governor_status(args, input), // 1137
|args, input, _| bi_governor_reset(args, input), // 1138
// self-healing: minimal repair context + rollback-bracketed attempts
|args, input, _| bi_diagnose(args, input), // 1139
bi_try_repair, // 1140
|args, input, _| bi_plan_diff(args, input), // 1141
|args, input, _| bi_rm(args, input), // 1142
|args, input, _| bi_rmdir(args, input), // 1143
|args, input, _| bi_touch(args, input), // 1144
|args, input, _| bi_cd(args, input), // 1145
|args, input, _| bi_rbac_register(args, input), // 1146
|args, input, _| bi_rbac_login(args, input), // 1147
|args, input, _| bi_rbac_logout(args, input), // 1148
|args, input, _| bi_rbac_session(args, input), // 1149
// Appended, never inserted. A row added mid-table shifts every index after
// it and silently re-points every `map.insert` above it -- the failure mode
// that left `mkdir` on a placeholder. New rows go on the end.
|args, input, _| bi_db_csv_query(args, input), // 1150
|args, input, _| bi_db_json_query(args, input), // 1151
|args, input, _| bi_db_json_to_csv(args, input), // 1152
|args, input, _| bi_clipboard_types(args, input), // 1153
|args, input, _| bi_crypto_random_string(args, input), // 1154
|args, input, _| bi_input_number(args, input), // 1155
|args, input, _| bi_input_timeout(args, input), // 1156
|args, input, _| bi_whoami(args, input), // 1157
|args, input, _| bi_named_hash("md5", args, input), // 1158
|args, input, _| bi_named_hash("sha256", args, input), // 1159
];
/// The dispatch row that serves `name`, if the fast table has one.
fn fast_index(name: &str) -> Option<usize> {
BUILTIN_LOOKUP
.get(name)
.copied()
.filter(|&i| i < BUILTIN_DISPATCH.len())
}
/// Run a fast-table builtin by its row. The one place `BUILTIN_DISPATCH` is
/// indexed, reached only from `call_with_input_inner` after the gate
/// (`tests/one_door.rs`). It takes a resolved row rather than a name so the
/// caller can decide before giving up its arguments, which then move.
fn fast_builtin_lookup(
index: usize,
args: Vec<Value>,
input: Option<Value>,
env: &mut Env,
) -> Result<Value> {
BUILTIN_DISPATCH[index](args, input, env)
}
// --------------- Public entry points ---------------
/// Levenshtein edit distance, bounded by `max`: returns `None` as soon as the
/// best remaining possibility exceeds it. The bound is what makes scanning the
/// whole builtin table on a *failed* call cheap.
fn edit_distance_within(a: &str, b: &str, max: usize) -> Option<usize> {
let (la, lb) = (a.chars().count(), b.chars().count());
if la.abs_diff(lb) > max {
return None;
}
let mut prev: Vec<usize> = (0..=lb).collect();
let mut cur = vec![0usize; lb + 1];
for (i, ca) in a.chars().enumerate() {
cur[0] = i + 1;
let mut row_best = cur[0];
for (j, cb) in b.chars().enumerate() {
let cost = usize::from(ca != cb);
cur[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(cur[j] + 1);
row_best = row_best.min(cur[j + 1]);
}
if row_best > max {
return None;
}
std::mem::swap(&mut prev, &mut cur);
}
let d = prev[lb];
if d <= max {
Some(d)
} else {
None
}
}
/// The nearest **real** builtin names to `name`, closest first (at most 3).
///
/// Searched against the live `BUILTIN_LOOKUP` table rather than a hand-written
/// list, so it cannot drift out of date as builtins are added, and it never
/// falls back to a default guess: when nothing is within the edit budget the
/// result is empty. The budget scales with name length (1 for very short names,
/// where every neighbour is a different command; up to 3 for long dotted ones).
pub fn did_you_mean(name: &str) -> Vec<String> {
nearest_names(name, BUILTIN_LOOKUP.keys().copied())
}
/// The nearest names to `name` from an arbitrary candidate set, closest first
/// (at most 3), or empty when nothing is within the edit budget.
///
/// Generic over the candidates so the same matching serves both misspelled
/// builtins and misspelled *record fields* — which is how module functions
/// (`file.read`) resolve, and therefore how most agent typos actually present.
pub fn nearest_names<'a>(name: &str, candidates: impl Iterator<Item = &'a str>) -> Vec<String> {
// Budget scales with length: for a very short name every neighbour is a
// different command, so only an exact-ish match is a real suggestion.
let budget = match name.chars().count() {
0..=3 => 1,
4..=7 => 2,
_ => 3,
};
let lower = name.to_lowercase();
let mut scored: Vec<(usize, &'a str)> = candidates
.filter_map(|cand| {
edit_distance_within(&lower, &cand.to_lowercase(), budget).map(|d| (d, cand))
})
.collect();
// Deterministic: distance first, then name, so the same typo always yields
// the same suggestions (an agent replaying a repair must see stable output).
scored.sort_unstable_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1)));
scored.truncate(3);
scored.into_iter().map(|(_, n)| n.to_string()).collect()
}
pub fn call(name: &str, args: Vec<Value>, env: &mut Env) -> Result<Value> {
call_with_input(name, args, None, env)
}
/// Call a builtin with optional piped input.
///
/// This is the single boundary where a builtin failure becomes an agent-visible
/// error, so it is also where every failure is guaranteed a stable code
/// (`safety::ensure_structured`): a builtin that returned bare `anyhow` prose
/// surfaces as `E_UNKNOWN` with `retryable: false` rather than as an
/// unbranchable string. Errors that already carry a specific code pass through
/// untouched.
pub fn call_with_input(
name: &str,
args: Vec<Value>,
input: Option<Value>,
env: &mut Env,
) -> Result<Value> {
refuse_irreversible_in_transaction(name)
.map_err(|e| crate::safety::ensure_structured(name, e))?;
// A builtin with a declared signature is checked before its body runs, so
// it cannot answer a call it could not honour. Undeclared builtins are
// unaffected — see `crate::signature` for why this exists and why the
// migration is incremental.
crate::signature::validate(name, &args, input.as_ref())
.map_err(|e| crate::safety::ensure_structured(name, e))?;
call_with_input_inner(name, args, input, env)
.map_err(|e| crate::safety::ensure_structured(name, e))
}
lazy_static::lazy_static! {
/// The dispatch indices of `tx::IRREVERSIBLE`, so every spelling of a
/// listed implementation is refused, not only the listed one.
static ref TX_IRREVERSIBLE_INDICES: rustc_hash::FxHashSet<usize> = crate::tx::IRREVERSIBLE
.iter()
.filter_map(|name| BUILTIN_LOOKUP.get(name).copied())
.collect();
/// The function each listed fallback name's arm calls. A fallback-only
/// spelling is refused when its arm calls one of these, as a table
/// spelling is refused by its dispatch index.
static ref TX_IRREVERSIBLE_CALLEES: rustc_hash::FxHashSet<&'static str> = FALLBACK_BUILTINS
.iter()
.filter(|(name, _)| crate::tx::IRREVERSIBLE.contains(name))
.map(|(_, callee)| *callee)
.collect();
}
/// Refuse a builtin a rollback cannot undo while a transaction is active
/// (`E_POLICY_DENY`), before anything runs.
fn refuse_irreversible_in_transaction(name: &str) -> Result<()> {
let listed = match BUILTIN_LOOKUP.get(name) {
Some(idx) => TX_IRREVERSIBLE_INDICES.contains(idx),
None => FALLBACK_BUILTINS
.binary_search_by_key(&name, |(n, _)| n)
.is_ok_and(|i| TX_IRREVERSIBLE_CALLEES.contains(FALLBACK_BUILTINS[i].1)),
};
if listed && crate::tx::is_active() {
return Err(crate::safety::policy_deny(
name,
format!(
"{name}: cannot run inside a filesystem transaction: a rollback cannot undo what it does; NOTHING WAS CHANGED"
),
"commit or roll back the open transaction (tx_commit or tx_rollback), then run it outside",
));
}
Ok(())
}
/// Every name served by the fallback `match` in [`call_with_input_inner`],
/// paired with the function that arm calls, sorted by name.
///
/// The dispatcher has two halves. `BUILTIN_LOOKUP` is a table, so it can be
/// asked what it serves and which names share an implementation; the fallback is
/// a literal `match`, so it can do neither. That asymmetry made the second half
/// invisible to everything reasoning about "the set of names an agent can call"
/// — `tests/effect_ratchet.rs` skipped it, and `eval::is_effect_free` treated all
/// 113 fallback-only names as unknown.
///
/// The function name is carried because *aliasing* is the part that matters for
/// safety: `"vault-convert" | "vault_convert" => bi_vault_convert(..)` is one
/// arm serving two names, and `safety::effect_of` classifies by name. Without
/// this pairing there was no way for `src/` to know the two were the same call.
///
/// Hand-written *and* drift-proof: `tests/fallback_dispatch.rs` parses the arms
/// out of this file's own source and asserts equality, so an arm added without a
/// matching entry here fails the build. That mechanical check is the difference
/// between a second source of truth and a second place to forget.
///
/// Three of these names (`ai_backends`, `type_of`, `typeof`) are also in
/// `BUILTIN_LOOKUP`, which wins, so their arms are unreachable. They are listed
/// anyway because this mirrors the *match*, not the reachable subset — keeping
/// the equality exact is what makes the drift test simple enough to trust.
pub const FALLBACK_BUILTINS: &[(&str, &str)] = &[
("ForEach-Object", "bi_foreach_object"),
("Get-Content", "bi_get_content"),
("Get-Files", "bi_get_files"),
("Group-Object", "bi_group_object"),
("Measure-Object", "bi_measure_object"),
("Select-Object", "bi_select_object"),
("Sort-Object", "bi_sort_object"),
("Where-Object", "bi_where_object"),
("agent", "bi_agent"),
("ai", "bi_ai"),
("ai-add-route", "bi_ai_add_route"),
("ai-backends", "bi_ai_backends"),
("ai-complete", "bi_ai_complete"),
("ai-convert-model", "bi_ai_convert_model"),
("ai-cost", "bi_ai_cost"),
("ai-detect", "bi_ai_detect"),
("ai-detect-format", "bi_ai_detect_format"),
("ai-explain", "bi_ai_explain"),
("ai-fix", "bi_ai_fix"),
("ai-gateway", "bi_ai_gateway"),
("ai-gpu-memory", "bi_ai_gpu_memory"),
("ai-load-balancing", "bi_ai_load_balancing"),
("ai-local-backends", "bi_ai_local_backends"),
("ai-local-embed", "bi_ai_local_embed"),
("ai-local-generate", "bi_ai_local_generate"),
("ai-local-load", "bi_ai_local_load"),
("ai-local-unload", "bi_ai_local_unload"),
("ai-model-fits", "bi_ai_model_fits"),
("ai-providers", "bi_ai_providers"),
("ai-registry-stats", "bi_ai_registry_stats"),
("ai-reset-usage", "bi_ai_reset_usage"),
("ai-routes", "bi_ai_routes"),
("ai-set-default", "bi_ai_set_default"),
("ai-set-load-balancing", "bi_ai_set_load_balancing"),
("ai-suggest", "bi_ai_suggest"),
("ai-supported-conversions", "bi_ai_supported_conversions"),
("ai-usage", "bi_ai_usage"),
("ai_add_route", "bi_ai_add_route"),
("ai_backends", "bi_ai_backends"),
("ai_convert_model", "bi_ai_convert_model"),
("ai_cost", "bi_ai_cost"),
("ai_detect", "bi_ai_detect"),
("ai_detect_format", "bi_ai_detect_format"),
("ai_gateway", "bi_ai_gateway"),
("ai_gpu_memory", "bi_ai_gpu_memory"),
("ai_load_balancing", "bi_ai_load_balancing"),
("ai_local_backends", "bi_ai_local_backends"),
("ai_local_embed", "bi_ai_local_embed"),
("ai_local_generate", "bi_ai_local_generate"),
("ai_local_load", "bi_ai_local_load"),
("ai_local_unload", "bi_ai_local_unload"),
("ai_model_fits", "bi_ai_model_fits"),
("ai_providers", "bi_ai_providers"),
("ai_registry_stats", "bi_ai_registry_stats"),
("ai_reset_usage", "bi_ai_reset_usage"),
("ai_routes", "bi_ai_routes"),
("ai_set_default", "bi_ai_set_default"),
("ai_set_load_balancing", "bi_ai_set_load_balancing"),
("ai_supported_conversions", "bi_ai_supported_conversions"),
("ai_usage", "bi_ai_usage"),
("columns", "bi_columns"),
("complete", "bi_ai_complete"),
("describe", "bi_describe"),
("explain", "bi_ai_explain"),
("explain_effects", "bi_explain_effects"),
("fix", "bi_ai_fix"),
("foreach", "bi_foreach_object"),
("foreach-object", "bi_foreach_object"),
("from-csv", "bi_from_csv"),
("from-json", "bi_from_json"),
("from-yaml", "bi_from_yaml"),
("from_csv", "bi_from_csv"),
("from_json", "bi_from_json"),
("from_yaml", "bi_from_yaml"),
("get-content", "bi_get_content"),
("get-files", "bi_get_files"),
("group", "bi_group_object"),
("group-object", "bi_group_object"),
("group_by", "bi_group_object"),
("handle", "bi_handle"),
("handle_drop", "bi_handle_drop"),
("handles", "bi_handles"),
("irongate", "bi_ai_gateway"),
("is_type", "bi_is_type"),
("journal", "bi_journal"),
("journal_clear", "bi_journal_clear"),
("lines", "bi_lines"),
("measure", "bi_measure_object"),
("measure-object", "bi_measure_object"),
("ontology_json", "bi_ontology_json"),
("ontology_jsonld", "bi_ontology_jsonld"),
("ontology_owl", "bi_ontology_owl"),
("ontology_shacl", "bi_ontology_shacl"),
("open", "bi_open"),
("record_summary", "bi_record_summary"),
("rewind", "bi_undo"),
("select", "bi_select_object"),
("select-object", "bi_select_object"),
("sort-object", "bi_sort_object"),
("sql_value", "bi_sql_value"),
("str", "bi_str"),
("suggest", "bi_ai_suggest"),
("swarm", "bi_swarm"),
("to-csv", "bi_to_csv"),
("to-json", "bi_to_json"),
("to-yaml", "bi_to_yaml"),
("to_csv", "bi_to_csv"),
("to_json", "bi_to_json"),
("to_string", "bi_str"),
("to_yaml", "bi_to_yaml"),
("type_fields", "bi_type_fields"),
("type_name", "bi_type_name"),
("type_of", "bi_typeof"),
("type_schema", "bi_type_schema"),
("typeof", "bi_typeof"),
("undo", "bi_undo"),
("vault-conversions", "bi_vault_conversions"),
("vault-convert", "bi_vault_convert"),
("vault-models", "bi_vault_models"),
("vault_conversions", "bi_vault_conversions"),
("vault_convert", "bi_vault_convert"),
("vault_models", "bi_vault_models"),
("where-object", "bi_where_object"),
];
/// Whether the builtin dispatcher serves `name` — either half of it.
///
/// A name this answers `false` for is not a builtin: it may be a user-defined
/// function, a module member, or nothing at all. Callers reasoning about safety
/// must treat `false` as *unknown*, never as *harmless*.
pub fn is_dispatched(name: &str) -> bool {
BUILTIN_LOOKUP.contains_key(name)
|| FALLBACK_BUILTINS
.binary_search_by_key(&name, |(n, _)| n)
.is_ok()
}
/// Names that reach the same implementation, grouped — across both halves of
/// the dispatcher.
///
/// `BUILTIN_LOOKUP` aliases share a dispatch index (`ls` and `list` are both 8);
/// fallback aliases share an arm. Dispatch is by implementation while
/// `safety::effect_of` classifies by *name*, so wherever a group's spellings are
/// classified differently, the weakest one is the one an agent will reach for.
/// This is what lets `safety` close that gap without a second hand-written list.
///
/// Groups of one are omitted: they cannot disagree with anything.
pub fn alias_groups() -> Vec<Vec<&'static str>> {
let mut by_impl: HashMap<String, Vec<&'static str>> = HashMap::new();
for (name, idx) in BUILTIN_LOOKUP.iter() {
by_impl.entry(format!("#{idx}")).or_default().push(name);
}
for (name, callee) in FALLBACK_BUILTINS {
// A name the fast table also serves is dispatched there, so it belongs
// with that implementation, not with the arm it shadows.
if BUILTIN_LOOKUP.contains_key(name) {
continue;
}
by_impl.entry((*callee).to_string()).or_default().push(name);
}
let mut out: Vec<Vec<&'static str>> = by_impl
.into_values()
.filter(|names| names.len() > 1)
.map(|mut names| {
names.sort_unstable();
names.dedup();
names
})
.filter(|names| names.len() > 1)
.collect();
out.sort();
out
}
fn call_with_input_inner(
name: &str,
args: Vec<Value>,
input: Option<Value>,
env: &mut Env,
) -> Result<Value> {
// Enforce policy before the builtin runs. `effect_of` only ever *described*
// an operation's danger; this is where the description starts to constrain.
// Builtins that guard themselves are skipped so one action is not admitted
// twice.
crate::safety::guard_dispatch(name, &args)?;
// Capture what a mutating call is about to overwrite, so the session can be
// rewound. This is the one place every builtin passes through, which is why
// it is here rather than at ~300 call sites — the same reason this function
// is already the single boundary for error structuring.
//
// Agent surface only. The comment here used to say the common path cost
// one `matches!`; in fact `mark()` locked the journal and, while it was
// empty, listed the session directory on disk -- every builtin call, in
// every mode. With journalling off (the human default) nothing is marked,
// and `rollback_to(usize::MAX)` below is a no-op.
//
// Only a call that can write is journalled (`record_before` ignores the
// rest), so only such a call needs to know whether journalling is on --
// which is three environment reads, 27% of a `len([x])` call when every
// call asked. Skipping the mark for a non-writing builtin also stops a
// failing `map` from rolling back the entry for a write its lambda did
// make.
let effect = crate::safety::effect_of(name);
let journal_on = matches!(
effect,
crate::safety::Effect::WriteLocal | crate::safety::Effect::Destructive
) && crate::journal::enabled();
let journal_mark = if journal_on {
let m = crate::journal::mark();
crate::journal::record_before(name, effect, &args);
m
} else {
usize::MAX
};
// Try fast lookup first. Decide *which* table serves the name before
// handing the arguments over, so they move instead of being copied: this
// cloned the whole pipe input on every call -- twice for a builtin in the
// fallback match -- and `cat("src/*.rs") | lines | len` spent most of its
// time deep-copying 128,000 strings it was about to count.
if let Some(index) = fast_index(name) {
let result = fast_builtin_lookup(index, args, input, env);
// A call that failed changed nothing, so it must leave nothing behind to
// undo. Without this a write refused by the workspace jail still left an
// entry, and `undo()` answered `complete: false` about an operation that
// never ran.
if result.is_err() {
crate::journal::rollback_to(journal_mark);
}
return result;
}
// The workflow engine's own surface. It lives in its own module because it
// is the one family of builtins that has to bridge to an async engine, and
// that bridge has to know which kind of runtime it was called from.
if crate::workflow_builtins::serves(name) {
let result = crate::workflow_builtins::dispatch(name, args, input);
if result.is_err() {
crate::journal::rollback_to(journal_mark);
}
return result;
}
// Fall back to the comprehensive match for functions not in fast lookup
match name {
// PowerShell-style cmdlets (not in fast lookup)
"Get-Files" | "get-files" => bi_get_files(args, input),
"Get-Content" | "get-content" => bi_get_content(args, input),
"Select-Object" | "select-object" | "select" => bi_select_object(args, input),
"Where-Object" | "where-object" => bi_where_object(args, input, env),
"ForEach-Object" | "foreach-object" | "foreach" => bi_foreach_object(args, input, env),
"Sort-Object" | "sort-object" => bi_sort_object(args, input),
"Group-Object" | "group-object" | "group" | "group_by" => bi_group_object(args, input),
"Measure-Object" | "measure-object" | "measure" => bi_measure_object(args, input),
// Nushell-style data commands (not in fast lookup)
"from-json" | "from_json" => bi_from_json(args, input),
"sql_value" => bi_sql_value(args, input),
"lines" => bi_lines(args, input),
"open" => bi_open(args, input),
"explain_effects" => bi_explain_effects(args),
"to-json" | "to_json" => bi_to_json(args, input),
"from-csv" | "from_csv" => bi_from_csv(args, input),
"to-csv" | "to_csv" => bi_to_csv(args, input),
"from-yaml" | "from_yaml" => bi_from_yaml(args, input),
"to-yaml" | "to_yaml" => bi_to_yaml(args, input),
"columns" => bi_columns(args, input),
"describe" => bi_describe(args, input),
// AI-enhanced commands (not in fast lookup)
"ai" => bi_ai(args, input, env),
"ai-suggest" | "suggest" => bi_ai_suggest(args, input, env),
"ai-explain" | "explain" => bi_ai_explain(args, input, env),
"ai-complete" | "complete" => bi_ai_complete(args, input, env),
"ai-fix" | "fix" => bi_ai_fix(args, input, env),
// AI/Agents (not in fast lookup)
"agent" => bi_agent(args, input, env),
"swarm" => bi_swarm(args, input, env),
// AI Backend Detection (not in fast lookup)
"ai_backends" | "ai-backends" => bi_ai_backends(args, input),
"ai_detect" | "ai-detect" => bi_ai_detect(args, input),
// AI Cost Tracking
"ai_usage" | "ai-usage" => bi_ai_usage(),
"ai_cost" | "ai-cost" => bi_ai_cost(),
"ai_reset_usage" | "ai-reset-usage" => bi_ai_reset_usage(input),
// GPU Memory Management
"ai_gpu_memory" | "ai-gpu-memory" => bi_ai_gpu_memory(input),
"ai_model_fits" | "ai-model-fits" => bi_ai_model_fits(args, input),
// Provider Routing & Registry
"ai_providers" | "ai-providers" => bi_ai_providers(),
"ai_routes" | "ai-routes" => bi_ai_routes(),
"ai_add_route" | "ai-add-route" => bi_ai_add_route(args, input),
"ai_set_default" | "ai-set-default" => bi_ai_set_default(args, input),
"ai_registry_stats" | "ai-registry-stats" => bi_ai_registry_stats(),
"ai_set_load_balancing" | "ai-set-load-balancing" => bi_ai_set_load_balancing(args, input),
"ai_load_balancing" | "ai-load-balancing" => bi_ai_load_balancing(),
// Model Format Conversion
"ai_convert_model" | "ai-convert-model" => bi_ai_convert_model(args, input),
// Kept on one line: tests/fallback_dispatch.rs reads each arm's callee
// from its own line, and rustfmt would wrap this one into a block.
#[rustfmt::skip]
"ai_supported_conversions" | "ai-supported-conversions" => bi_ai_supported_conversions(input),
"ai_detect_format" | "ai-detect-format" => bi_ai_detect_format(args),
// The NERVOSYS stack: routing, inference, model store
"ai_gateway" | "ai-gateway" | "irongate" => bi_ai_gateway(input),
"vault_models" | "vault-models" => bi_vault_models(input),
"vault_conversions" | "vault-conversions" => bi_vault_conversions(input),
"vault_convert" | "vault-convert" => bi_vault_convert(args, input),
// Local Inference
"ai_local_backends" | "ai-local-backends" => bi_ai_local_backends(),
"ai_local_load" | "ai-local-load" => bi_ai_local_load(args, input),
"ai_local_generate" | "ai-local-generate" => bi_ai_local_generate(args, input),
"ai_local_embed" | "ai-local-embed" => bi_ai_local_embed(args, input),
"ai_local_unload" | "ai-local-unload" => bi_ai_local_unload(args, input),
// Runtime Type Introspection
"str" | "to_string" => bi_str(args, input),
"typeof" | "type_of" => bi_typeof(args, input),
"type_name" => bi_type_name(args, input),
"type_fields" => bi_type_fields(args, input),
"type_schema" => bi_type_schema(args, input),
"is_type" => bi_is_type(args),
// Ontology Export
"ontology_json" => bi_ontology_json(),
"ontology_jsonld" => bi_ontology_jsonld(),
"ontology_owl" => bi_ontology_owl(),
"ontology_shacl" => bi_ontology_shacl(),
// Result handles (§ see crate::handles). Registered here rather than in
// BUILTIN_LOOKUP because that table is index-coupled to BUILTIN_DISPATCH;
// reachability is identical and the handle summary carries its own usage
// hint, so discovery does not depend on the ontology listing.
"handle" => bi_handle(args, input),
"handles" => bi_handles(),
"handle_drop" => bi_handle_drop(args),
// Reversible sessions (see crate::journal).
"undo" | "rewind" => bi_undo(args, input),
"journal" => bi_journal(),
"record_summary" => bi_record_summary(args, input),
"journal_clear" => bi_journal_clear(input),
// A `[builtins]` entry of a loaded plugin (see crate::plugins). Core names
// are matched above and a plugin cannot register one, so this never
// shadows a builtin.
_ if crate::plugins::is_manifest_builtin(name) => {
crate::plugins::call_manifest_builtin(name, args, input)
}
_ => Err(crate::safety::unknown_builtin(name, did_you_mean(name))),
}
}
// --------------- Helpers: type extraction ---------------
fn expect_array<'a>(name: &str, v: &'a Value) -> Result<&'a [Value]> {
if let Value::Array(a) = v {
Ok(a.as_slice())
} else {
Err(crate::safety::bad_arg(name, "an array", v.type_name()))
}
}
fn expect_int(name: &str, v: &Value) -> Result<i64> {
match v {
Value::Int(n) => Ok(*n),
Value::Float(f) if f.fract() == 0.0 => Ok(*f as i64),
_ => Err(crate::safety::bad_arg(name, "an integer", v.type_name())),
}
}
/// A lookup key: a name, id, token or query. A string, or an integer id;
/// anything else was stringified and looked up, so `job_status({})` answered
/// "no such job" and `user_roles([1])` answered "no roles" at exit 0.
fn key_arg(builtin: &str, v: Value) -> Result<String> {
match v {
Value::Str(s) => Ok(s),
Value::Int(n) => Ok(n.to_string()),
other => Err(crate::safety::bad_arg(
builtin,
"a string or integer id",
other.type_name(),
)),
}
}
/// Refuse a piped subject for a builtin that does not document one.
///
/// Without this, `"x" | mcp_servers()` ran as if the pipe were not there: the
/// body never looked at its input, and nothing said so.
fn refuse_piped(name: &str, input: &Option<Value>) -> Result<()> {
if input.is_some() {
return Err(crate::safety::bad_arg(
name,
"no piped subject (a direct call)",
"a piped value",
));
}
Ok(())
}
/// A catalogue tool that ran and failed, as the coded `E_TOOL_FAILED`.
///
/// `tool_failed` takes an exit status; an MCP tool result carries only text,
/// so this builds the same error without inventing a status.
fn mcp_tool_failed(tool: &str, text: &str) -> anyhow::Error {
let said = text.trim();
anyhow::Error::new(crate::safety::SafetyError {
code: crate::safety::ErrorCode::ToolFailed,
message: format!(
"mcp_call: tool `{tool}` failed{}",
if said.is_empty() {
" and printed nothing".to_string()
} else {
format!(": {said}")
}
),
builtin: "mcp_call".to_string(),
hint: format!(
"the tool `{tool}` rejected this call; check its arguments against mcp_tools()"
),
approval: None,
did_you_mean: Vec::new(),
expected: format!("`{tool}` to succeed"),
got: "a failure".to_string(),
})
}
fn expect_string<'a>(name: &str, v: &'a Value) -> Result<&'a str> {
if let Value::Str(s) = v {
Ok(s.as_str())
} else {
Err(crate::safety::bad_arg(name, "a string", v.type_name()))
}
}
fn need_lambda<'a>(v: &'a Value, name: &str) -> Result<&'a Lambda> {
if let Value::Lambda(l) = v {
Ok(l)
} else {
Err(crate::safety::bad_arg(name, "a lambda", v.type_name()))
}
}
/// Fetch a required positional argument, or return a structured `E_BAD_ARG`
/// naming what was expected when it is missing — the arity counterpart to the
/// `expect_*` type helpers. Lets a builtin surface a catchable, branchable
/// missing-argument error instead of ad-hoc prose (or an index panic).
#[allow(dead_code)]
fn arg<'a>(builtin: &str, args: &'a [Value], idx: usize, expected: &str) -> Result<&'a Value> {
args.get(idx)
.ok_or_else(|| crate::safety::bad_arg(builtin, expected, "nothing"))
}
/// Gate a network-egress builtin through the safety guard with the `Network`
/// effect, so the resource governor (`IRONSHELL_MAX_NET`) bounds egress and the
/// audit log records the request. `Network` is policy-`allow` in agent mode, so
/// this meters and audits rather than prompting.
///
/// Egress containment lives in `safety::guard`, which every `Network` call
/// reaches, not only these: `IRONSHELL_NET_ALLOW` (hosts and their subdomains,
/// `;`- or `,`-separated, `*` for any) refuses other destinations with
/// `E_POLICY_DENY`, and in agent mode with no allowlist a request needs
/// approval.
pub(crate) fn guard_network(builtin: &str, url: &str) -> Result<()> {
// Every one of these builtins hands the URL to `curl` or `wget` in a
// positional slot, so a leading `-` is read as an option rather than an
// address — and `curl -K<file>` reads a config file that can set `output`
// and `url`, which turns a fetch into an arbitrary write. Checking it here
// rather than at each of the fourteen call sites is the point: this is the
// one door every network builtin already goes through, so a site added
// later inherits the check instead of having to remember it. No legitimate
// URL begins with `-`.
crate::safety::reject_option_like(builtin, &[url.to_string()])?;
crate::safety::guard(crate::safety::GuardCtx {
builtin,
effect: crate::safety::Effect::Network,
what: "request",
targets: vec![url.to_string()],
blast_radius: serde_json::json!({ "url": url }),
reversible: true,
fs_paths: false,
})?;
Ok(())
}
/// Jail a caller-supplied path that the builtin is about to write, exactly as
/// `file_write` does — resolve it workspace-relative, then guard it as
/// `WriteLocal` with `fs_paths: true`.
///
/// A download has two effects and the effect taxonomy carries one. `web_download`,
/// `wget_download` and `web_upload_file` are classified `Network` by the `web_*`
/// prefix rule, they call [`guard_network`] on the URL, and they are in
/// `SELF_GUARDED` — so they look fully gated at every place a reader would check.
/// None of that touches the file. `guard_network` passes `fs_paths: false` and
/// `Effect::Network`, and the jail in `safety::guard` fires only when the effect
/// `is_filesystem()` *and* `fs_paths` is set, so the path went through no check
/// at all: `web.download <url> C:\outside\workspace\x` wrote there in agent mode
/// while `file.write` to the identical path was refused.
///
/// Returns the workspace-resolved path, so a relative path lands inside the jail
/// rather than beside the process's working directory — the same treatment
/// `file_write` gives it, and it also removes the option-injection question,
/// since a resolved absolute path cannot begin with `-`.
fn guard_local_write(builtin: &str, path: &str) -> Result<String> {
let path = crate::safety::resolve_path_str(path);
crate::safety::guard(crate::safety::GuardCtx {
builtin,
effect: crate::safety::Effect::WriteLocal,
what: "write",
targets: vec![path.clone()],
blast_radius: serde_json::json!({ "path": path }),
reversible: true,
fs_paths: true,
})?;
Ok(path)
}
/// The `Destructive` counterpart of [`guard_local_write`], for a move.
///
/// `fs::rename` removes the source and can overwrite the destination, so it is
/// not the same promise as a write — `Destructive` is `approve` in agent mode
/// rather than `allow`. That is a real change in what an agent may do without
/// asking, and it is the honest one: `file_delete_lines` is already
/// `Destructive` for altering a file in place, and moving one over another is
/// not the smaller act.
fn guard_local_move(builtin: &str, path: &str) -> Result<String> {
let path = crate::safety::resolve_path_str(path);
crate::safety::guard(crate::safety::GuardCtx {
builtin,
effect: crate::safety::Effect::Destructive,
what: "move",
targets: vec![path.clone()],
blast_radius: serde_json::json!({ "path": path }),
reversible: false,
fs_paths: true,
})?;
Ok(path)
}
// There is deliberately no read counterpart for `web_upload_file`. The jail in
// `safety::guard` fires only for `WriteLocal` and `Destructive` — `ReadLocal` is
// unjailed by design, and `file.read` of a path outside the workspace followed
// by `web.post` is already an allowed pair. Jailing the upload would be a policy
// change invented here rather than an inconsistency being corrected, so it is
// left as it is and named instead. `tests/network_write_jail.rs` pins the
// distinction so it stays a decision rather than an oversight.
/// Evaluate a lambda with N positional arguments by temporarily binding its `params`
/// in the environment, then `eval_expr` on its body. Restores env afterwards.
fn call_lambda(lam: &Lambda, args: &[Value], env: &mut Env) -> Result<Value> {
// Finding 13: unbounded self-recursion used to overflow the stack and abort
// the process, which no error handling could catch. The guard is held for
// the whole call, so the depth unwinds even when an inner call returns Err.
let _depth = crate::safety::enter_call()?;
let params = &lam.params;
if params.len() != args.len() {
return Err(crate::safety::arg_err(format!(
"lambda expected {} args, got {}",
params.len(),
args.len()
)));
}
// Save and clear pipe input to prevent it from leaking into lambda
// evaluation.
//
// This used to *clone* the pipe input rather than take it, and that clone
// was the single largest cost in the shell. In `xs | map(fn(x) => ...)`
// the pipe input at this point is the whole of `xs`, so the collection was
// deep-copied once per element: 500 records of GitHub issue data cost
// 237 ms for a closure that ignored its argument entirely, and `map` over
// N integers ran in O(N^2) -- 10,000 elements took 515 ms where jq took 7.
// Taking the value moves it; `set_input` below puts it back, so the
// observable behaviour is identical.
let saved_pipe = env.take_input();
let (result, _) = bind_eval_unbind(lam, args.to_vec(), env, false);
// Restore pipe input
env.set_input(saved_pipe);
result
}
/// Call a lambda with arguments it may keep: they are moved into the
/// parameter bindings instead of cloned, and, with `give_back`, taken out again
/// afterwards and returned. A lambda body is one expression with no way to
/// rebind a parameter, so what comes back is what went in -- which is what
/// lets `where` test an element without copying it first.
pub(crate) fn call_lambda_owned(
lam: &Lambda,
args: Vec<Value>,
env: &mut Env,
give_back: bool,
) -> Result<(Value, Vec<Value>)> {
let _depth = crate::safety::enter_call()?;
if lam.params.len() != args.len() {
return Err(crate::safety::arg_err(format!(
"lambda expected {} args, got {}",
lam.params.len(),
args.len()
)));
}
let saved_pipe = env.take_input();
let (result, back) = bind_eval_unbind(lam, args, env, give_back);
env.set_input(saved_pipe);
result.map(|r| (r, back))
}
/// `call_lambda_owned` for the one-parameter lambda, which is nearly every
/// `map`/`where` predicate: the same binding discipline with no vectors, so a
/// call allocates nothing beyond the parameter's name.
pub(crate) fn call_lambda_one(
lam: &Lambda,
arg: Value,
env: &mut Env,
give_back: bool,
) -> Result<(Value, Option<Value>)> {
let _depth = crate::safety::enter_call()?;
let [p] = lam.params.as_slice() else {
return Err(crate::safety::arg_err(format!(
"lambda expected {} args, got 1",
lam.params.len()
)));
};
let saved_pipe = env.take_input();
let prev = env.replace_param(p, arg);
let result = eval_expr(&lam.body, env);
let cur = env.restore_param(p, prev);
env.set_input(saved_pipe);
result.map(|r| (r, if give_back { cur } else { None }))
}
/// Bind `args` to the lambda's parameters, evaluate its body, and restore
/// whatever those names were bound to before. Previous bindings are moved
/// out and back rather than cloned.
fn bind_eval_unbind(
lam: &Lambda,
args: Vec<Value>,
env: &mut Env,
give_back: bool,
) -> (Result<Value>, Vec<Value>) {
let mut saved: Vec<Option<Value>> = Vec::with_capacity(args.len());
for (p, a) in lam.params.iter().zip(args) {
saved.push(env.replace_param(p, a));
}
let result = eval_expr(&lam.body, env);
let mut back = Vec::new();
for (p, prev) in lam.params.iter().zip(saved).rev() {
let cur = env.restore_param(p, prev);
if give_back {
back.push(cur.unwrap_or(Value::Null));
}
}
back.reverse();
(result, back)
}
/// Allocate parameter names once for a collection, not once per element.
/// Restore shadowed values on success and on errors, including nested calls.
fn with_param_slots<T>(
lam: &Lambda,
env: &mut Env,
run: impl FnOnce(&mut Env) -> Result<T>,
) -> Result<T> {
let saved: Vec<_> = lam
.params
.iter()
.map(|name| env.replace_param(name, Value::Null))
.collect();
let result = run(env);
for (name, old) in lam.params.iter().zip(saved).rev() {
env.restore_param(name, old);
}
result
}
// --------------- General builtins ---------------
fn bi_help() -> Result<Value> {
let txt = r#"Æther (æ) built-ins:
- help # this help
- clear # clear screen (prints ANSI)
- echo <...values> # echo stringified values
- print <value> # pretty-print value (returns text)
Data / pipelines:
- map <array> <fn(x)=> expr> # map over array
- where <array> <fn(x)=> predicate> # filter array
- reduce <array> <fn(x,y)=> expr> <init> # fold array
- take <array> <n> # take first n elements
- first <array> # get first element
- last <array> # get last element
- any <array> [fn(x)=> pred] # check if any match
- all <array> [fn(x)=> pred] # check if all match
- keys <record> # get record keys
- len <array|record|string> # get length
- type_of <value> # get type name
String functions:
- split <string> <delimiter> # split into array
- join <array> <delimiter> # join into string
- trim <string> # remove whitespace
- upper <string> # to uppercase
- lower <string> # to lowercase
- replace <string> <old> <new> # replace substring
- contains <string> <substring> # check if contains
- starts_with <string> <prefix> # check prefix
- ends_with <string> <suffix> # check suffix
Array functions:
- flatten <array> # flatten nested arrays
- reverse <array|string> # reverse order
- slice <array|string> <start> [end] # extract slice
- range <end> OR <start> <end> [step] # generate range
- zip <array1> <array2> # zip into pairs
- push <array> <item> # append item
- concat <array1> <array2> # concatenate
- sort <array> # sort array
- uniq <array> # unique values
Math functions:
- abs <number> # absolute value
- min <a> <b> # minimum
- max <a> <b> # maximum
- floor <number> # round down
- ceil <number> # round up
- round <number> # round nearest
- sqrt <number> # square root
- pow <base> <exponent> # power
Utility functions:
- exit [code] # exit program
- env <key> # get env variable
- set_env <key> <value> # set env variable
- sleep <seconds> # sleep duration
- time () # current timestamp
- json_parse <json_string> # parse JSON
- json_stringify <value> # to JSON string
File system:
- ls [path] # list directory
- pwd () # current directory
- cat <file> # read file
- read_text <file> # read file (alias)
- head <file> [n] # first n lines
- tail <file> [n] # last n lines
- find <path> [pattern] # find files
- grep <file|array> <pattern> # search lines
- wc <file|array> # count lines
HTTP:
- http_get <url> # fetch URL → {url,status,headers,body}
AI Backend Detection:
- ai_backends # list available AI backends with details
- ai_detect # auto-select best available AI backend
MCP Server Integration (Discovery):
- mcp_servers # list available MCP servers and tools
- mcp_detect [endpoint] # find specific MCP server or first available
- mcp_cache_clear # clear MCP detection cache
- mcp_cache_status # get cache status info
MCP Server/Client (Enhanced):
- mcp_server [config] # create local MCP server for AI tools
- mcp_tools [filter] # list MCP tools with schemas
- mcp_call <name> [args] # call MCP tool by name
- mcp_resources [type] # list MCP resources
- mcp_prompts [name] # list prompts or get specific one
- mcp_connect <endpoint> # connect to external MCP server
OS Tools (40+ tools for AI agents):
- tools [category] # list available OS tools
- tool_info <name> # get detailed tool information
- tool_search <query> # search tools by keyword
- tool_schema <name> # get tool input schema
- tool_exec <name> [args] # execute tool
Syntax Knowledge Base:
- syntax_get <id> # get syntax entry by ID
- syntax_list [category] # list all syntax IDs (optional filter)
- syntax_search <query> # search entries by keyword
- syntax_add <record> # add new syntax entry
- syntax_categories () # list all categories
- ab_encode <msg_type> <opcode> <payload> # encode AgenticBinary message
- ab_decode <bytes> # decode AgenticBinary message
AI / Agents (require ai module present):
- agent <goal> [tools...] [max_steps] [dry_run]
- swarm <json-config|record> OR <goal> [tools...] [max_steps] [dry_run]
Recursive Language Models (RLM):
- rlm_agent <goal> [tools] [config] # run agent that can spawn subagents
- rlm_config {opts} # create RLM configuration
- rlm_stats () # get hierarchy statistics
- rlm_spawn <name> <goal> [tools] # spawn a single subagent
RLM Configuration Options:
{max_depth: 5, max_agents: 50, timeout: 60, trace: true}
Examples:
# Pipelines
[1,2,3] | map(fn(x)=> x*2) | reduce(fn(a,b)=> a+b, 0)
[5,4,3,2,1] | where(fn(x)=> x>2) | take(2) | print
# String operations
"hello,world" | split(",") | map(fn(s)=> s | upper()) | join("-")
# Array operations
range(10) | where(fn(x)=> x % 2 == 0) | print
[[1,2],[3,4]] | flatten() | reverse()
# Math
range(1, 11) | map(fn(x)=> pow(x, 2)) | reduce(fn(a,b)=> a+b, 0)
# JSON
{name: "Alice", age: 30} | json_stringify() | json_parse()
AI & MCP Integration:
model = ai_detect() # auto-detect AI backend
server = mcp_detect() # find MCP server
agent("task", model, server.tools) # use both together
"#;
Ok(Value::Str(txt.to_string()))
}
fn bi_clear() -> Result<Value> {
// CSI 2J (clear screen) + CSI H (cursor home)
Ok(Value::Str("\u{1b}[2J\u{1b}[H".to_string()))
}
fn bi_echo(args: &[Value], input: Option<Value>) -> Result<Value> {
// `"hello" | echo()` answered "": the dispatch row dropped the pipe
// input, so a piped value vanished. With no arguments, echo the subject.
if args.is_empty() {
if let Some(v) = input {
return Ok(Value::Str(format_value_one_line(&v)));
}
}
let mut s = String::new();
for (i, v) in args.iter().enumerate() {
if i > 0 {
s.push(' ');
}
s.push_str(&format_value_one_line(v));
}
Ok(Value::Str(s))
}
fn bi_print(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
// Determine the target Value (either piped input or first arg).
let target = if let Some(v) = input {
v
} else {
args.into_iter()
.next()
.ok_or_else(|| crate::safety::arg_err("print expects a value"))?
};
// (Removed an unused strip_ansi helper; tests have their own)
// Print the pretty display to stdout. `display_full`, not
// `display_inline`: the latter caps at 80 characters, which silently threw
// away the tail of anything longer that a script tried to print.
let pretty =
crate::value::pretty::display_full(&target, &crate::value::pretty::Theme::for_stdout());
println!("{}", pretty);
// Helper to strip ANSI color sequences from a string
fn strip_ansi(s: &str) -> String {
let mut out = String::new();
let mut it = s.chars().peekable();
while let Some(ch) = it.next() {
if ch == '\x1b' {
if let Some('[') = it.peek() {
it.next();
while let Some(&c) = it.peek() {
it.next();
if c == 'm' {
break;
}
}
continue;
}
}
out.push(ch);
}
out
}
// Return behavior: always return the pretty inline display (ANSI stripped)
// as a `Str`. This keeps the REPL and tests consistent regardless of
// whether the input was a bare string or another value.
Ok(Value::Str(strip_ansi(&pretty)))
}
/// http_get — fetch one http(s) URL. Usage: http_get(url) or url | http_get.
/// Returns {url, status, headers, body}. The URL passes the network guard and
/// the SSRF rules, and each redirect target passes the same rules before it is
/// followed, at most 5 of them. A non-2xx status is an answer, not a failure.
/// The body is read up to 16 MiB; more is E_BUDGET_EXCEEDED. A request that gets
/// no answer is a coded E_IO, never an empty body. Invalid UTF-8 is replaced.
fn bi_http_get(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
use std::io::Read as _;
net_refuse_in_transaction("http_get")?;
if args.len() > 1 {
return Err(crate::safety::arg_err(format!(
"http_get takes one URL, got {}",
args.len()
)));
}
let url_val = match (input, args.into_iter().next()) {
(Some(_), Some(_)) => {
return Err(crate::safety::arg_err(
"http_get takes its URL either as an argument or piped in, not both",
))
}
(Some(v), None) | (None, Some(v)) => v,
(None, None) => return Err(crate::safety::arg_err("http_get expects URL string/uri")),
};
let url = match url_val {
Value::Str(s) | Value::Uri(s) => s,
other => {
return Err(crate::safety::bad_arg(
"http_get",
"a URL String",
other.type_name(),
))
}
};
guard_network("http_get", &url)?;
// Each hop is checked, and connected to at the address its check accepted,
// so neither the first URL nor a redirect target is resolved twice.
let first = crate::security::check_http_url("http_get", &url)?;
let validated_url = first.url.clone();
let mut hop = first;
let mut answer = None;
for _ in 0..=HTTP_MAX_REDIRECTS {
let client = crate::security::create_hop_client(Some(&hop))?;
let sent = client
.get(&hop.url)
.send()
.map_err(|e| net_io_error("http_get", "request", &e))?;
let location = sent
.headers()
.get(reqwest::header::LOCATION)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
match crate::security::judged_redirect(
"http_get",
&hop.url,
sent.status().as_u16(),
location.as_deref(),
)? {
Some(next) => hop = next,
None => {
answer = Some(sent);
break;
}
}
}
let mut resp = answer.ok_or_else(|| {
crate::safety::budget_exceeded(
"http_get",
format!("more than {HTTP_MAX_REDIRECTS} redirects"),
"request the final URL directly",
)
})?;
let status = i64::from(resp.status().as_u16());
let mut headers_rec = BTreeMap::<String, Value>::new();
for (k, v) in resp.headers().iter() {
headers_rec.insert(
k.to_string(),
Value::Str(String::from_utf8_lossy(v.as_bytes()).into_owned()),
);
}
let mut bytes = Vec::new();
std::io::Read::take(&mut resp, HTTP_BODY_MAX + 1)
.read_to_end(&mut bytes)
.map_err(|e| net_io_error("http_get", "reading the body", &e))?;
if bytes.len() as u64 > HTTP_BODY_MAX {
return Err(crate::safety::budget_exceeded(
"http_get",
format!("the body is more than {HTTP_BODY_MAX} bytes"),
"fetch a smaller resource",
));
}
let mut out = BTreeMap::<String, Value>::new();
out.insert("url".into(), Value::Str(validated_url));
out.insert("status".into(), Value::Int(status));
out.insert("headers".into(), Value::Record(headers_rec));
out.insert(
"body".into(),
Value::Str(String::from_utf8_lossy(&bytes).into_owned()),
);
Ok(Value::Record(out))
}
fn bi_call(args: Vec<Value>, input: Option<Value>, env: &mut Env) -> Result<Value> {
// call <name> [args...]
let name_val = args
.first()
.cloned()
.ok_or_else(|| crate::safety::bad_arg("call", "a builtin name", "nothing"))?;
let name = match name_val {
Value::Str(s) | Value::Uri(s) => s,
other => {
return Err(crate::safety::arg_err(format!(
"call: expected String builtin name, got {:?}",
other
)));
}
};
// remaining args (after name)
let mut rem: Vec<Value> = Vec::new();
for v in args.into_iter().skip(1) {
rem.push(v);
}
// If input was provided, pass it as pipe input to the called builtin
call_with_input(&name, rem, input, env)
}
// --------------- Data / pipeline builtins ---------------
fn bi_map(args: Vec<Value>, input: Option<Value>, env: &mut Env) -> Result<Value> {
let piped = input.is_some();
let lam_val = if piped {
arg("map", &args, 0, "a lambda")?
} else {
arg("map", &args, 1, "a lambda")?
};
let lam = need_lambda(lam_val, "map")?;
// The array is owned here -- piped or the first argument -- so it is
// consumed, not copied. This cloned the whole input before starting and
// then every element again for the lambda.
let arr_val = match input {
Some(v) => v,
None => args
.first()
.cloned()
.ok_or_else(|| crate::safety::bad_arg("map", "an array", "nothing"))?,
};
expect_array("map", &arr_val)?;
let Value::Array(items) = arr_val else {
unreachable!("expect_array accepted it")
};
// Both `fn(x, i)` and `fn(x)` are supported. This used to be discovered by
// calling with two arguments and retrying with one on failure, which made
// three copies of every element (`.cloned()`, `v_clone`, and the call's
// own `v.clone()`) to keep the retry path alive. The lambda knows its own
// arity, so ask it once.
let two_arg = lam.params.len() == 2;
let mut out = Vec::with_capacity(items.len());
if items.is_empty() {
return Ok(Value::Array(out));
}
with_param_slots(lam, env, |env| {
for (i, v) in items.into_iter().enumerate() {
let y = if two_arg {
call_lambda_owned(lam, vec![v, Value::Int(i as i64)], env, false)?.0
} else {
call_lambda_one(lam, v, env, false)?.0
};
out.push(y);
}
Ok(Value::Array(out))
})
}
fn bi_where(args: Vec<Value>, input: Option<Value>, env: &mut Env) -> Result<Value> {
let piped = input.is_some();
let lam_val = if piped {
arg("where", &args, 0, "a lambda")?
} else {
arg("where", &args, 1, "a lambda")?
};
let lam = need_lambda(lam_val, "where")?;
// See `bi_map`: the array is consumed. Each element is moved into the
// predicate and handed back, so a kept element is never copied.
let arr_val = match input {
Some(v) => v,
None => args
.first()
.cloned()
.ok_or_else(|| crate::safety::bad_arg("where", "an array", "nothing"))?,
};
expect_array("where", &arr_val)?;
let Value::Array(items) = arr_val else {
unreachable!("expect_array accepted it")
};
// See `bi_map`: ask the lambda its arity once instead of calling twice and
// keeping three copies of each element alive to make the retry possible.
let two_arg = lam.params.len() == 2;
let mut out = Vec::new();
if items.is_empty() {
return Ok(Value::Array(out));
}
with_param_slots(lam, env, |env| {
for (i, v) in items.into_iter().enumerate() {
let (keep_val, v) = if two_arg {
let (k, mut back) =
call_lambda_owned(lam, vec![v, Value::Int(i as i64)], env, true)?;
(k, back.swap_remove(0))
} else {
let (k, back) = call_lambda_one(lam, v, env, true)?;
(k, back.unwrap_or(Value::Null))
};
match keep_val {
Value::Bool(true) => out.push(v),
Value::Bool(false) => {}
other => {
return Err(crate::safety::arg_err(format!(
"where predicate must return Bool, got {:?}",
other
)))
}
}
}
Ok(Value::Array(out))
})
}
fn bi_reduce(args: Vec<Value>, input: Option<Value>, env: &mut Env) -> Result<Value> {
// Cases:
// - piping: [1,2,3] | reduce fn(a,b)=>... 0
// - direct: reduce [1,2,3] fn(a,b)=>... 0
// Determine arr, lambda index and init index without moving `input`
let piped = input.is_some();
if !piped && args.len() < 3 {
return Err(crate::safety::bad_arg(
"reduce",
"<array> <fn(acc,x)> <init>",
"nothing",
));
}
let (lam_idx, init_idx) = if piped {
(0usize, 1usize)
} else {
(1usize, 2usize)
};
let lam = need_lambda(arg("reduce", &args, lam_idx, "a lambda")?, "reduce")?;
let init = arg("reduce", &args, init_idx, "an initial value")?.clone();
// Select arity before running the body. Retrying on any error copied the
// accumulator and hid an indexed lambda's body error behind an arity error.
let indexed = lam.params.len() == 3;
let arr_val = input.unwrap_or_else(|| args[0].clone());
expect_array("reduce", &arr_val)?;
let Value::Array(arr) = arr_val else {
unreachable!("expect_array accepted it")
};
let mut acc = init;
if arr.is_empty() {
return Ok(acc);
}
with_param_slots(lam, env, |env| {
for (i, v) in arr.into_iter().enumerate() {
let mut values = vec![acc, v];
if indexed {
values.push(Value::Int(i as i64));
}
acc = call_lambda_owned(lam, values, env, false)?.0;
}
Ok(acc)
})
}
fn bi_take(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let (arr_val, n_idx) = if let Some(input_val) = input {
// SECURITY: Replace .unwrap() with explicit match (CVSS 7.1)
(input_val, 0usize)
} else {
if args.len() < 2 {
return Err(crate::safety::bad_arg("take", "<array> <n>", "nothing"));
}
(args[0].clone(), 1usize)
};
let n = expect_int(
"take",
args.get(n_idx)
.ok_or_else(|| crate::safety::arg_err("take missing n"))?,
)?;
let arr = expect_array("take", &arr_val)?;
let take_n = if n < 0 { 0 } else { n as usize };
Ok(Value::Array(arr.iter().take(take_n).cloned().collect()))
}
fn bi_keys(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let val = if let Some(input_val) = input {
input_val
} else if !args.is_empty() {
args[0].clone()
} else {
return Err(crate::safety::arg_err(
"keys: requires a Record as input or argument",
));
};
match val {
Value::Record(map) => {
let keys: Vec<Value> = map.keys().map(|k| Value::Str(k.clone())).collect();
Ok(Value::Array(keys))
}
_ => Err(crate::safety::arg_err(format!(
"keys: requires a Record, got {:?}",
val
))),
}
}
fn bi_values(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let val = if let Some(input_val) = input {
input_val
} else if !args.is_empty() {
args[0].clone()
} else {
return Err(crate::safety::arg_err(
"values: requires a Record as input or argument",
));
};
match val {
Value::Record(map) => {
let values: Vec<Value> = map.values().cloned().collect();
Ok(Value::Array(values))
}
_ => Err(crate::safety::arg_err(format!(
"values: requires a Record, got {:?}",
val
))),
}
}
fn bi_sum(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let arr = if let Some(input_val) = input {
input_val
} else if !args.is_empty() {
args[0].clone()
} else {
return Err(crate::safety::arg_err(
"sum: requires an Array as input or argument",
));
};
match arr {
Value::Array(items) => {
let mut int_sum: i64 = 0;
let mut float_sum: f64 = 0.0;
let mut has_float = false;
for item in items {
match item {
Value::Int(n) => {
if has_float {
float_sum += n as f64;
} else {
int_sum += n;
}
}
Value::Float(f) => {
if !has_float {
float_sum = int_sum as f64;
has_float = true;
}
float_sum += f;
}
_ => {
return Err(crate::safety::arg_err(format!(
"sum: array must contain only numbers, got {:?}",
item
)))
}
}
}
if has_float {
Ok(Value::Float(float_sum))
} else {
Ok(Value::Int(int_sum))
}
}
_ => Err(crate::safety::arg_err(format!(
"sum: requires an Array, got {:?}",
arr
))),
}
}
fn bi_unique(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let array = if let Some(input) = input {
match input {
Value::Array(arr) => arr,
Value::Str(s) => {
let lines: Vec<Value> =
s.lines().map(|line| Value::Str(line.to_string())).collect();
lines
}
_ => {
return Err(crate::safety::arg_err(
"unique: input must be an array or string",
))
}
}
} else if !args.is_empty() {
match &args[0] {
Value::Array(arr) => arr.clone(),
other => {
return Err(crate::safety::bad_arg(
"unique",
"argument: Array",
other.type_name(),
))
}
}
} else {
return Err(crate::safety::bad_arg(
"unique",
"a piped Array or String",
"a direct call with no subject",
));
};
// True unique - removes all duplicates (not just consecutive)
let mut seen = std::collections::HashSet::new();
let mut unique = Vec::new();
for item in array {
let key = format!("{:?}", item);
if seen.insert(key) {
unique.push(item);
}
}
Ok(Value::Array(unique))
}
fn bi_avg(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let arr = if let Some(input_val) = input {
input_val
} else if !args.is_empty() {
args[0].clone()
} else {
return Err(crate::safety::arg_err(
"avg: requires an Array as input or argument",
));
};
match arr {
Value::Array(items) => {
if items.is_empty() {
return Ok(Value::Float(f64::NAN));
}
let mut sum: f64 = 0.0;
for item in &items {
match item {
Value::Int(n) => sum += *n as f64,
Value::Float(f) => sum += *f,
_ => {
return Err(crate::safety::arg_err(format!(
"avg: array must contain only numbers, got {:?}",
item
)))
}
}
}
Ok(Value::Float(sum / items.len() as f64))
}
_ => Err(crate::safety::arg_err(format!(
"avg: requires an Array, got {:?}",
arr
))),
}
}
fn bi_product(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let arr = if let Some(input_val) = input {
input_val
} else if !args.is_empty() {
args[0].clone()
} else {
return Err(crate::safety::arg_err(
"product: requires an Array as input or argument",
));
};
match arr {
Value::Array(items) => {
let mut int_product: i64 = 1;
let mut float_product: f64 = 1.0;
let mut has_float = false;
for item in items {
match item {
Value::Int(n) => {
if has_float {
float_product *= n as f64;
} else {
int_product = int_product.checked_mul(n).ok_or_else(|| {
crate::safety::bad_arg(
"product",
"a product within Int range",
"integer overflow",
)
})?;
}
}
Value::Float(f) => {
if !has_float {
float_product = int_product as f64;
has_float = true;
}
float_product *= f;
}
_ => {
return Err(crate::safety::bad_arg(
"product",
"an array of numbers",
item.type_name(),
))
}
}
}
if has_float {
Ok(Value::Float(float_product))
} else {
Ok(Value::Int(int_product))
}
}
_ => Err(crate::safety::arg_err(format!(
"product: requires an Array, got {:?}",
arr
))),
}
}
// --------------- Configuration builtins ---------------
/// Get the entire configuration as a Record
fn bi_config(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let config = get_config();
let mut map = BTreeMap::new();
// Shell settings
let mut shell = BTreeMap::new();
shell.insert(
"show_banner".to_string(),
Value::Bool(config.shell.show_banner),
);
shell.insert("show_tips".to_string(), Value::Bool(config.shell.show_tips));
shell.insert("vi_mode".to_string(), Value::Bool(config.shell.vi_mode));
shell.insert("auto_cd".to_string(), Value::Bool(config.shell.auto_cd));
shell.insert(
"glob_expansion".to_string(),
Value::Bool(config.shell.glob_expansion),
);
shell.insert(
"command_correction".to_string(),
Value::Bool(config.shell.command_correction),
);
shell.insert(
"bell_style".to_string(),
Value::Str(config.shell.bell_style.clone()),
);
map.insert("shell".to_string(), Value::Record(shell));
// Color settings
let mut colors = BTreeMap::new();
colors.insert("enabled".to_string(), Value::Bool(config.colors.enabled));
colors.insert("theme".to_string(), Value::Str(config.colors.theme.clone()));
colors.insert("force".to_string(), Value::Bool(config.colors.force));
colors.insert(
"true_color".to_string(),
Value::Bool(config.colors.true_color),
);
map.insert("colors".to_string(), Value::Record(colors));
// Prompt settings
let mut prompt = BTreeMap::new();
prompt.insert(
"format".to_string(),
Value::Str(config.prompt.format.clone()),
);
prompt.insert("show_git".to_string(), Value::Bool(config.prompt.show_git));
prompt.insert(
"show_time".to_string(),
Value::Bool(config.prompt.show_time),
);
prompt.insert(
"time_threshold_ms".to_string(),
Value::Int(config.prompt.time_threshold_ms as i64),
);
map.insert("prompt".to_string(), Value::Record(prompt));
// AI settings
let mut ai = BTreeMap::new();
ai.insert(
"default_provider".to_string(),
Value::Str(config.ai.default_provider.clone()),
);
ai.insert(
"default_model".to_string(),
Value::Str(config.ai.default_model.clone()),
);
ai.insert(
"suggestions".to_string(),
Value::Bool(config.ai.suggestions),
);
ai.insert(
"max_tokens".to_string(),
Value::Int(config.ai.max_tokens as i64),
);
ai.insert(
"temperature".to_string(),
Value::Float(config.ai.temperature as f64),
);
ai.insert("streaming".to_string(), Value::Bool(config.ai.streaming));
ai.insert(
"max_agent_steps".to_string(),
Value::Int(config.ai.max_agent_steps as i64),
);
map.insert("ai".to_string(), Value::Record(ai));
// History settings
let mut history = BTreeMap::new();
history.insert("enabled".to_string(), Value::Bool(config.history.enabled));
history.insert(
"max_size".to_string(),
Value::Int(config.history.max_size as i64),
);
history.insert(
"ignore_duplicates".to_string(),
Value::Bool(config.history.ignore_duplicates),
);
history.insert(
"ignore_space".to_string(),
Value::Bool(config.history.ignore_space),
);
history.insert("share".to_string(), Value::Bool(config.history.share));
history.insert(
"timestamps".to_string(),
Value::Bool(config.history.timestamps),
);
map.insert("history".to_string(), Value::Record(history));
Ok(Value::Record(map))
}
/// Get a specific config value by path (e.g., "colors.theme" or "ai.default_model")
fn bi_config_get(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
if args.is_empty() {
return Err(crate::safety::arg_err(
"config_get: requires a path argument (e.g., \"colors.theme\")",
));
}
let path = match &args[0] {
Value::Str(s) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"config_get",
"path: String",
other.type_name(),
))
}
};
let config = get_config();
let parts: Vec<&str> = path.split('.').collect();
match parts.as_slice() {
// Shell settings
["shell", "show_banner"] => Ok(Value::Bool(config.shell.show_banner)),
["shell", "show_tips"] => Ok(Value::Bool(config.shell.show_tips)),
["shell", "vi_mode"] => Ok(Value::Bool(config.shell.vi_mode)),
["shell", "auto_cd"] => Ok(Value::Bool(config.shell.auto_cd)),
["shell", "glob_expansion"] => Ok(Value::Bool(config.shell.glob_expansion)),
["shell", "command_correction"] => Ok(Value::Bool(config.shell.command_correction)),
["shell", "bell_style"] => Ok(Value::Str(config.shell.bell_style.clone())),
// Color settings
["colors", "enabled"] => Ok(Value::Bool(config.colors.enabled)),
["colors", "theme"] => Ok(Value::Str(config.colors.theme.clone())),
["colors", "force"] => Ok(Value::Bool(config.colors.force)),
["colors", "true_color"] => Ok(Value::Bool(config.colors.true_color)),
// Prompt settings
["prompt", "format"] => Ok(Value::Str(config.prompt.format.clone())),
["prompt", "show_git"] => Ok(Value::Bool(config.prompt.show_git)),
["prompt", "show_time"] => Ok(Value::Bool(config.prompt.show_time)),
["prompt", "time_threshold_ms"] => Ok(Value::Int(config.prompt.time_threshold_ms as i64)),
// AI settings
["ai", "default_provider"] => Ok(Value::Str(config.ai.default_provider.clone())),
["ai", "default_model"] => Ok(Value::Str(config.ai.default_model.clone())),
["ai", "suggestions"] => Ok(Value::Bool(config.ai.suggestions)),
["ai", "max_tokens"] => Ok(Value::Int(config.ai.max_tokens as i64)),
["ai", "temperature"] => Ok(Value::Float(config.ai.temperature as f64)),
["ai", "streaming"] => Ok(Value::Bool(config.ai.streaming)),
["ai", "max_agent_steps"] => Ok(Value::Int(config.ai.max_agent_steps as i64)),
// History settings
["history", "enabled"] => Ok(Value::Bool(config.history.enabled)),
["history", "max_size"] => Ok(Value::Int(config.history.max_size as i64)),
["history", "ignore_duplicates"] => Ok(Value::Bool(config.history.ignore_duplicates)),
["history", "ignore_space"] => Ok(Value::Bool(config.history.ignore_space)),
["history", "share"] => Ok(Value::Bool(config.history.share)),
["history", "timestamps"] => Ok(Value::Bool(config.history.timestamps)),
_ => Err(crate::safety::not_found("config_get", "config path", &path)),
}
}
/// Set a config value (saves to config file)
/// Note: This modifies the config file but changes only take effect after reload
/// The settable paths, for the refusal that names them.
const CONFIG_SETTABLE: &str = "colors.enabled|theme|force|true_color, shell.show_banner|show_tips|vi_mode|auto_cd, ai.default_model|default_provider|max_tokens|streaming, history.enabled|max_size";
/// Refuse a value the config path cannot hold, before the file is read. Every
/// settable path has a fixed type and range; `max_tokens` used to be cast with
/// `as u32`, so -1 became 4294967295 and was saved.
fn config_value_ok(path: &str, value: &Value) -> Result<()> {
const B: &str = "config_set";
const BOOL_PATHS: &[&str] = &[
"colors.enabled",
"colors.force",
"colors.true_color",
"shell.show_banner",
"shell.show_tips",
"shell.vi_mode",
"shell.auto_cd",
"ai.streaming",
"history.enabled",
];
let refuse = |expected: String| {
crate::safety::bad_arg(B, &expected, &format!("{} {value}", value.type_name()))
};
if BOOL_PATHS.contains(&path) {
return match value {
Value::Bool(_) => Ok(()),
_ => Err(refuse(format!("{path}: Bool"))),
};
}
match path {
"colors.theme" => match value {
Value::Str(s)
if (1..=64).contains(&s.len())
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') =>
{
Ok(())
}
_ => Err(refuse(
"colors.theme: a String of 1 to 64 letters, digits, '_' or '-'".to_string(),
)),
},
"ai.default_model" | "ai.default_provider" => match value {
Value::Str(s)
if !s.trim().is_empty() && s.len() <= 256 && !s.chars().any(char::is_control) =>
{
Ok(())
}
_ => Err(refuse(format!(
"{path}: a non-blank String of at most 256 bytes without control characters"
))),
},
"ai.max_tokens" => match value {
Value::Int(n) if (1..=i64::from(u32::MAX)).contains(n) => Ok(()),
_ => Err(refuse(
"ai.max_tokens: an Int in 1..=4294967295".to_string(),
)),
},
"history.max_size" => match value {
Value::Int(n) if (1..=1_000_000).contains(n) => Ok(()),
_ => Err(refuse(
"history.max_size: an Int in 1..=1000000".to_string(),
)),
},
_ => Err(crate::safety::bad_arg(
B,
&format!("a settable path: {CONFIG_SETTABLE}"),
path,
)),
}
}
// Keys config_set stores but nothing in the shell reads yet; its reply says so.
const CONFIG_NOT_APPLIED: &[&str] = &[
"colors.force",
"shell.show_tips",
"shell.vi_mode",
"shell.auto_cd",
"ai.default_model",
"ai.default_provider",
"ai.max_tokens",
"ai.streaming",
"history.max_size",
];
/// config_set(path, value) - set one config value, save the config file that
/// `ShellConfig::config_file` names, and reload it into the running shell.
/// The path and value are checked before the file is read; a value of the
/// wrong type or outside its range is E_BAD_ARG and nothing is saved.
fn bi_config_set(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "config_set";
k8s_no_input(B, &input)?;
let path = match args.first() {
Some(Value::Str(s)) => s.clone(),
Some(other) => return Err(crate::safety::bad_arg(B, "path: String", other.type_name())),
None => {
return Err(crate::safety::arg_err(
"config_set requires path and value arguments",
))
}
};
let value = args
.get(1)
.ok_or_else(|| crate::safety::arg_err("config_set requires path and value arguments"))?;
config_value_ok(&path, value)?;
let mut config = ShellConfig::load().map_err(|e| {
crate::safety::bad_state(
B,
&format!("the config file could not be read: {e}"),
"config_path",
)
})?;
match (path.as_str(), value) {
("colors.enabled", Value::Bool(v)) => config.colors.enabled = *v,
("colors.theme", Value::Str(v)) => config.colors.theme = v.clone(),
("colors.force", Value::Bool(v)) => config.colors.force = *v,
("colors.true_color", Value::Bool(v)) => config.colors.true_color = *v,
("shell.show_banner", Value::Bool(v)) => config.shell.show_banner = *v,
("shell.show_tips", Value::Bool(v)) => config.shell.show_tips = *v,
("shell.vi_mode", Value::Bool(v)) => config.shell.vi_mode = *v,
("shell.auto_cd", Value::Bool(v)) => config.shell.auto_cd = *v,
("ai.default_model", Value::Str(v)) => config.ai.default_model = v.clone(),
("ai.default_provider", Value::Str(v)) => config.ai.default_provider = v.clone(),
("ai.max_tokens", Value::Int(v)) => config.ai.max_tokens = *v as u32,
("ai.streaming", Value::Bool(v)) => config.ai.streaming = *v,
("history.enabled", Value::Bool(v)) => config.history.enabled = *v,
("history.max_size", Value::Int(v)) => config.history.max_size = *v as usize,
// config_value_ok admits only the pairs above; a refusal here would be
// a coded one rather than a panic.
_ => {
return Err(crate::safety::bad_arg(
B,
&format!("a settable path: {CONFIG_SETTABLE}"),
&path,
))
}
}
config.save().map_err(|e| {
crate::safety::bad_state(
B,
&format!("the config file could not be written: {e}"),
"config_path",
)
})?;
// Make the running shell use the saved value, as config_reload does.
reload_config().map_err(|e| {
crate::safety::bad_state(
B,
&format!("the saved configuration could not be reloaded: {e}"),
"fix the config file (see config_path()) and reload",
)
})?;
let shown = match value {
Value::Str(s) => s.clone(),
Value::Bool(b) => b.to_string(),
Value::Int(n) => n.to_string(),
other => other.type_name().to_string(),
};
if CONFIG_NOT_APPLIED.contains(&path.as_str()) {
return Ok(Value::Str(format!(
"Config saved: {path} = {shown} (not applied: the shell stores this key but nothing acts on it yet)"
)));
}
Ok(Value::Str(format!("Config saved: {path} = {shown}")))
}
/// Get config file paths
fn bi_config_path() -> Result<Value> {
let mut map = BTreeMap::new();
map.insert(
"config_file".to_string(),
Value::Str(ShellConfig::config_file().to_string_lossy().to_string()),
);
map.insert(
"config_dir".to_string(),
Value::Str(ShellConfig::config_dir().to_string_lossy().to_string()),
);
map.insert(
"data_dir".to_string(),
Value::Str(ShellConfig::data_dir().to_string_lossy().to_string()),
);
map.insert(
"cache_dir".to_string(),
Value::Str(ShellConfig::cache_dir().to_string_lossy().to_string()),
);
map.insert(
"history_file".to_string(),
Value::Str(ShellConfig::history_file().to_string_lossy().to_string()),
);
map.insert(
"plugins_dir".to_string(),
Value::Str(ShellConfig::plugins_dir().to_string_lossy().to_string()),
);
map.insert(
"init_script".to_string(),
Value::Str(ShellConfig::init_script().to_string_lossy().to_string()),
);
Ok(Value::Record(map))
}
/// Initialize config directories and create default config file
fn bi_config_init(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "config_init";
// Takes no arguments (declared so) and no pipeline input.
k8s_no_input(B, &input)?;
if !args.is_empty() {
return Err(crate::safety::bad_arg(
B,
"no arguments",
&format!("{} argument(s)", args.len()),
));
}
ShellConfig::init_dirs().map_err(|e| {
crate::safety::bad_state(
B,
&format!("the config directories could not be created: {e}"),
"config_path",
)
})?;
// The file the shell reads and `config_set` writes (IRONSHELL_CONFIG, or
// the existing config file). `create_new` never overwrites one.
let config_file = ShellConfig::config_file();
if let Some(parent) = config_file.parent() {
fs::create_dir_all(parent).map_err(|e| crate::safety::fs_error(B, parent, &e))?;
}
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&config_file)
{
Ok(mut file) => {
use std::io::Write as _;
file.write_all(ShellConfig::generate_default_config().as_bytes())
.map_err(|e| crate::safety::fs_error(B, &config_file, &e))?;
Ok(Value::Str(format!(
"Created config file: {}",
config_file.display()
)))
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(Value::Str(format!(
"Config file already exists: {}",
config_file.display()
))),
Err(e) => Err(crate::safety::fs_error(B, &config_file, &e)),
}
}
/// Reload configuration from disk
fn bi_config_reload() -> Result<Value> {
// The failure was an uncoded string; success was the prose
// "Configuration reloaded".
reload_config().map_err(|e| {
crate::safety::bad_state(
"config_reload",
&format!("the configuration could not be reloaded: {e}"),
"fix the config file (see config_path()) and reload",
)
})?;
Ok(Value::Bool(true))
}
/// List all available color themes
fn bi_themes() -> Result<Value> {
use crate::config::Theme;
let themes: Vec<Value> = Theme::list()
.iter()
.map(|s| Value::Str(s.to_string()))
.collect();
Ok(Value::Array(themes))
}
// ===================== Plugin Builtins =====================
/// List all registered plugins
fn bi_plugins(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
Ok(crate::plugins::bi_plugins_list())
}
/// Get detailed information about a plugin
fn bi_plugin_info(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let plugin_id = if let Some(Value::Str(id)) = input {
id
} else if !args.is_empty() {
match &args[0] {
Value::Str(id) => id.clone(),
_ => {
return Err(crate::safety::arg_err(
"plugin_info: requires plugin ID string",
))
}
}
} else {
return Err(crate::safety::arg_err("plugin_info: requires plugin ID"));
};
// Null for an unknown id read as "no information about it".
match crate::plugins::bi_plugin_info(&plugin_id) {
Value::Null => Err(crate::safety::not_found(
"plugin_info",
"plugin",
&plugin_id,
)),
info => Ok(info),
}
}
/// Enable a plugin by ID
fn bi_plugin_enable(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let plugin_id = if let Some(Value::Str(id)) = input {
id
} else if !args.is_empty() {
match &args[0] {
Value::Str(id) => id.clone(),
_ => {
return Err(crate::safety::arg_err(
"plugin_enable: requires plugin ID string",
))
}
}
} else {
return Err(crate::safety::arg_err("plugin_enable: requires plugin ID"));
};
crate::plugins::bi_plugin_enable(&plugin_id)
}
/// Disable a plugin by ID
fn bi_plugin_disable(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let plugin_id = if let Some(Value::Str(id)) = input {
id
} else if !args.is_empty() {
match &args[0] {
Value::Str(id) => id.clone(),
_ => {
return Err(crate::safety::arg_err(
"plugin_disable: requires plugin ID string",
))
}
}
} else {
return Err(crate::safety::arg_err("plugin_disable: requires plugin ID"));
};
crate::plugins::bi_plugin_disable(&plugin_id)
}
/// Load a plugin from a manifest file path
fn bi_plugin_load(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let path = if let Some(Value::Str(p)) = input {
p
} else if !args.is_empty() {
match &args[0] {
Value::Str(p) => p.clone(),
_ => return Err(crate::safety::arg_err("plugin_load: requires path string")),
}
} else {
return Err(crate::safety::arg_err(
"plugin_load: requires manifest path",
));
};
crate::plugins::load_plugin_from_manifest(&path)
}
/// Unload a plugin by ID
fn bi_plugin_unload(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let plugin_id = if let Some(Value::Str(id)) = input {
id
} else if !args.is_empty() {
match &args[0] {
Value::Str(id) => id.clone(),
_ => {
return Err(crate::safety::arg_err(
"plugin_unload: requires plugin ID string",
))
}
}
} else {
return Err(crate::safety::arg_err("plugin_unload: requires plugin ID"));
};
crate::plugins::unload_plugin(&plugin_id)
}
/// List all plugin categories
fn bi_plugin_categories() -> Result<Value> {
Ok(crate::plugins::bi_plugin_categories())
}
fn bi_len(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let val = if let Some(input_val) = input {
input_val
} else if !args.is_empty() {
args[0].clone()
} else {
return Err(crate::safety::arg_err("len: requires input or argument"));
};
let length = match val {
Value::Array(ref v) => v.len(),
Value::Record(ref m) => m.len(),
Value::Str(ref s) => s.len(),
_ => {
return Err(crate::safety::arg_err(format!(
"len: requires Array, Record, or String, got {:?}",
val
)));
}
};
Ok(Value::Int(length as i64))
}
/// `str(v)` — render a value as the string a user would see.
///
/// The language had no way to do this. `"n: " + 1` worked by coercion, but
/// there was no conversion function, so 41 sites across the shipped examples
/// called a `str()` that has never existed. `to_display_string` is the same
/// renderer `print` and string interpolation use, so the three agree.
fn bi_str(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let val = if let Some(v) = input {
v
} else if !args.is_empty() {
args[0].clone()
} else {
return Err(crate::safety::arg_err("str: requires input or argument"));
};
Ok(Value::Str(val.to_display_string()))
}
fn bi_type_of(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let val = if let Some(input_val) = input {
input_val
} else if !args.is_empty() {
args[0].clone()
} else {
return Err(crate::safety::arg_err(
"type_of: requires input or argument",
));
};
let type_str = match val {
Value::Null => "Null",
Value::Bool(_) => "Bool",
Value::Int(_) => "Int",
Value::Float(_) => "Float",
Value::Str(_) => "String",
Value::Uri(_) => "Uri",
Value::Array(_) => "Array",
Value::Record(_) => "Record",
Value::Table(_) => "Table",
Value::Lambda(_) => "Lambda",
Value::AsyncLambda(_) => "AsyncLambda",
Value::Future(_) => "Future",
Value::Builtin(_) => "Builtin",
Value::Error(_) => "Error",
};
Ok(Value::Str(type_str.to_string()))
}
/// is_error(value) - Check if a value is an Error
/// Args:
/// - value: Any value to check
/// Returns: Bool - true if the value is an Error, false otherwise
///
/// Example:
/// let e = throw "something went wrong"
/// is_error(e) # Returns true
/// is_error(42) # Returns false
fn bi_is_error(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let val = if let Some(input_val) = input {
input_val
} else if !args.is_empty() {
args[0].clone()
} else {
return Err(crate::safety::arg_err(
"is_error: requires input or argument",
));
};
let is_err = matches!(val, Value::Error(_));
Ok(Value::Bool(is_err))
}
// --------------- Debugging Tools ---------------
/// debug(value) - Print value with its type and return the value for chaining
/// Args:
/// - value: Any value to debug
/// Returns: The same value (allows chaining in pipelines)
///
/// Example:
/// let x = debug(42) # Prints: [DEBUG] Int: 42
/// [1,2,3] | debug | map(...) # Debug intermediate pipeline values
fn bi_debug(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let val = if let Some(input_val) = input {
input_val
} else if !args.is_empty() {
args[0].clone()
} else {
return Err(crate::safety::arg_err("debug: requires input or argument"));
};
let type_str = val.type_name();
let pretty =
crate::value::pretty::display_inline(&val, &crate::value::pretty::Theme::for_stdout());
eprintln!("[DEBUG] {}: {}", type_str, pretty);
// Return the value unchanged for pipeline chaining
Ok(val)
}
/// assert(condition, message?) - Assert that a condition is true
/// Args:
/// - condition: Boolean condition to check
/// - message: Optional error message (defaults to "assertion failed")
/// Returns: true if assertion passes
/// Throws: Error with message if condition is false
///
/// Example:
/// assert(x > 0, "x must be positive")
/// assert(len(arr) > 0) # Uses default message
fn bi_assert(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let (cond, msg) = if let Some(input_val) = input {
// Piped: input is condition, arg 0 is message
let message = args
.first()
.and_then(|v| match v {
Value::Str(s) => Some(s.clone()),
_ => None,
})
.unwrap_or_else(|| "assertion failed".to_string());
(input_val, message)
} else if !args.is_empty() {
// Args: arg 0 is condition, arg 1 is message
let message = args
.get(1)
.and_then(|v| match v {
Value::Str(s) => Some(s.clone()),
_ => None,
})
.unwrap_or_else(|| "assertion failed".to_string());
(args[0].clone(), message)
} else {
return Err(crate::safety::arg_err(
"assert: requires condition argument",
));
};
// Check if condition is truthy
let is_truthy = match &cond {
Value::Bool(b) => *b,
Value::Null => false,
Value::Error(_) => false,
Value::Int(n) => *n != 0,
Value::Float(f) => *f != 0.0,
Value::Str(s) => !s.is_empty(),
Value::Array(arr) => !arr.is_empty(),
Value::Record(rec) => !rec.is_empty(),
_ => true,
};
if is_truthy {
Ok(Value::Bool(true))
} else {
Ok(Value::Error(msg))
}
}
/// trace(label, value) - Print a labeled value for tracing and return the value
/// Args:
/// - label: String label to identify the trace point
/// - value: Value to trace (can also be piped)
/// Returns: The value unchanged (allows chaining)
///
/// Example:
/// let result = trace("input", x) | map(fn(n) => n * 2) | trace("output")
/// trace("step1", calculate())
fn bi_trace(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
// A label must be a string; a trace line is for a human, so a label that
// is not one is refused rather than rendered as Debug text.
let label_of = |v: &Value| -> Result<String> {
match v {
Value::Str(s) if s.len() <= 256 => Ok(s.clone()),
other => Err(crate::safety::bad_arg(
"trace",
"a label: a string of at most 256 bytes",
other.type_name(),
)),
}
};
let (label, val) = if let Some(input_val) = input {
// Piped: input is the value, and at most one argument (the label).
if args.len() > 1 {
return Err(crate::safety::bad_arg(
"trace",
"with piped input, at most one argument: the label",
&format!("{} arguments", args.len()),
));
}
let label = match args.first() {
Some(v) => label_of(v)?,
None => "trace".to_string(),
};
(label, input_val)
} else if args.len() == 2 {
// Two args: label, value
(label_of(&args[0])?, args[1].clone())
} else if args.len() == 1 {
// One arg: use default label
("trace".to_string(), args[0].clone())
} else {
return Err(crate::safety::bad_arg(
"trace",
"a value, or a label and a value",
&format!("{} arguments", args.len()),
));
};
let type_str = val.type_name();
let pretty =
crate::value::pretty::display_inline(&val, &crate::value::pretty::Theme::for_stdout());
// A trace line is not a dump: a large value is cut with an explicit marker
// instead of flooding stderr.
const MAX_TRACE_CHARS: usize = 4096;
let pretty = if pretty.chars().count() > MAX_TRACE_CHARS {
let head: String = pretty.chars().take(MAX_TRACE_CHARS).collect();
format!("{head}… [trace truncated]")
} else {
pretty
};
eprintln!("[TRACE {}] {}: {}", label, type_str, pretty);
// Return the value unchanged for pipeline chaining
Ok(val)
}
/// type_assert(value, expected_type) - Assert that a value has the expected type
/// Args:
/// - value: Any value to check
/// - expected_type: String name of expected type ("Int", "String", "Array", etc.)
/// Returns: The value if type matches
/// Throws: Error if type doesn't match
///
/// Example:
/// type_assert(x, "Int") # Passes if x is an Int
/// data | type_assert("Array") # Piped: asserts data is Array
fn bi_type_assert(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let (val, expected) = if let Some(input_val) = input {
// Piped: input is value, arg 0 is expected type
let expected = args.first().ok_or_else(|| {
crate::safety::arg_err("type_assert: requires expected type argument")
})?;
let expected_str = match expected {
Value::Str(s) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"type_assert",
"expected type: String",
other.type_name(),
))
}
};
(input_val, expected_str)
} else if args.len() >= 2 {
// Two args: value, expected_type
let expected_str = match &args[1] {
Value::Str(s) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"type_assert",
"expected type: String",
other.type_name(),
))
}
};
(args[0].clone(), expected_str)
} else {
return Err(crate::safety::arg_err(
"type_assert: requires value and expected type arguments",
));
};
let actual_type = val.type_name();
// Normalize type names for comparison (case-insensitive)
let expected_normalized = expected.to_lowercase();
let actual_normalized = actual_type.to_lowercase();
if expected_normalized == actual_normalized {
Ok(val)
} else {
Ok(Value::Error(format!(
"type assertion failed: expected {}, got {}",
expected, actual_type
)))
}
}
/// inspect(value) - Get detailed inspection of a value's structure
/// Args:
/// - value: Any value to inspect
/// Returns: Record with detailed information about the value
///
/// Example:
/// inspect([1,2,3]) # Returns {type: "Array", length: 3, items: [...]}
/// inspect({a: 1}) # Returns {type: "Record", keys: ["a"], values: [...]}
fn bi_inspect(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let val = if let Some(input_val) = input {
input_val
} else if !args.is_empty() {
args[0].clone()
} else {
return Err(crate::safety::arg_err(
"inspect: requires input or argument",
));
};
let mut info = BTreeMap::<String, Value>::new();
info.insert("type".to_string(), Value::Str(val.type_name().to_string()));
match &val {
Value::Null => {
info.insert("value".to_string(), Value::Null);
}
Value::Bool(b) => {
info.insert("value".to_string(), Value::Bool(*b));
}
Value::Int(n) => {
info.insert("value".to_string(), Value::Int(*n));
info.insert("is_negative".to_string(), Value::Bool(*n < 0));
info.insert("is_zero".to_string(), Value::Bool(*n == 0));
}
Value::Float(f) => {
info.insert("value".to_string(), Value::Float(*f));
info.insert("is_nan".to_string(), Value::Bool(f.is_nan()));
info.insert("is_infinite".to_string(), Value::Bool(f.is_infinite()));
info.insert("is_negative".to_string(), Value::Bool(*f < 0.0));
}
Value::Str(s) => {
info.insert("value".to_string(), Value::Str(s.clone()));
info.insert("length".to_string(), Value::Int(s.len() as i64));
info.insert("is_empty".to_string(), Value::Bool(s.is_empty()));
info.insert(
"char_count".to_string(),
Value::Int(s.chars().count() as i64),
);
}
Value::Uri(u) => {
info.insert("value".to_string(), Value::Str(u.clone()));
info.insert("length".to_string(), Value::Int(u.len() as i64));
}
Value::Array(arr) => {
info.insert("length".to_string(), Value::Int(arr.len() as i64));
info.insert("is_empty".to_string(), Value::Bool(arr.is_empty()));
if !arr.is_empty() {
info.insert("first".to_string(), arr[0].clone());
info.insert("last".to_string(), arr[arr.len() - 1].clone());
// Collect unique types in the array using a Vec with dedup
let mut type_strs: Vec<String> =
arr.iter().map(|v| v.type_name().to_string()).collect();
type_strs.sort();
type_strs.dedup();
let types: Vec<Value> = type_strs.into_iter().map(Value::Str).collect();
info.insert("element_types".to_string(), Value::Array(types));
}
info.insert("elements".to_string(), Value::Array(arr.clone()));
}
Value::Record(rec) => {
info.insert("length".to_string(), Value::Int(rec.len() as i64));
info.insert("is_empty".to_string(), Value::Bool(rec.is_empty()));
info.insert(
"keys".to_string(),
Value::Array(rec.keys().map(|k| Value::Str(k.clone())).collect()),
);
info.insert(
"values".to_string(),
Value::Array(rec.values().cloned().collect()),
);
info.insert("entries".to_string(), Value::Record(rec.clone()));
}
Value::Table(table) => {
info.insert("row_count".to_string(), Value::Int(table.rows.len() as i64));
info.insert("is_empty".to_string(), Value::Bool(table.rows.is_empty()));
info.insert(
"columns".to_string(),
Value::Array(table.schema.iter().map(|k| Value::Str(k.clone())).collect()),
);
info.insert(
"column_count".to_string(),
Value::Int(table.schema.len() as i64),
);
}
Value::Lambda(lambda) => {
info.insert(
"param_count".to_string(),
Value::Int(lambda.params.len() as i64),
);
info.insert(
"params".to_string(),
Value::Array(
lambda
.params
.iter()
.map(|p| Value::Str(p.clone()))
.collect(),
),
);
info.insert("body".to_string(), Value::Str(format!("{:?}", lambda.body)));
}
Value::AsyncLambda(lambda) => {
info.insert(
"param_count".to_string(),
Value::Int(lambda.params.len() as i64),
);
info.insert(
"params".to_string(),
Value::Array(
lambda
.params
.iter()
.map(|p| Value::Str(p.clone()))
.collect(),
),
);
info.insert("body".to_string(), Value::Str(format!("{:?}", lambda.body)));
info.insert("is_async".to_string(), Value::Bool(true));
}
Value::Future(_) => {
info.insert("pending".to_string(), Value::Bool(true));
info.insert(
"description".to_string(),
Value::Str("Unevaluated future - use await to execute".to_string()),
);
}
Value::Error(msg) => {
info.insert("message".to_string(), Value::Str(msg.clone()));
info.insert("is_error".to_string(), Value::Bool(true));
}
Value::Builtin(b) => {
info.insert("name".to_string(), Value::Str(b.name.clone()));
info.insert("is_builtin".to_string(), Value::Bool(true));
}
}
Ok(Value::Record(info))
}
// --------------- Platform Detection Functions ---------------
/// platform() - Get the current platform name
/// Returns: String - One of "windows", "linux", "macos", "bsd", "ios", "android"
///
/// Example:
/// platform() # Returns "windows" on Windows
fn bi_platform() -> Result<Value> {
use crate::os_tools::OperatingSystem;
let os = OperatingSystem::current();
let platform_name = match os {
OperatingSystem::Windows => "windows",
OperatingSystem::Linux => "linux",
OperatingSystem::MacOS => "macos",
OperatingSystem::BSD => "bsd",
OperatingSystem::iOS => "ios",
OperatingSystem::Android => "android",
};
Ok(Value::Str(platform_name.to_string()))
}
/// is_windows() - Check if running on Windows
/// Returns: Bool
///
/// Example:
/// if is_windows() then "Windows!" else "Not Windows"
fn bi_is_windows() -> Result<Value> {
Ok(Value::Bool(cfg!(target_os = "windows")))
}
/// is_linux() - Check if running on Linux (excluding Android)
/// Returns: Bool
///
/// Example:
/// if is_linux() then "Linux!" else "Not Linux"
fn bi_is_linux() -> Result<Value> {
#[cfg(target_os = "linux")]
{
// Exclude Android
let is_android = std::path::Path::new("/system/build.prop").exists();
Ok(Value::Bool(!is_android))
}
#[cfg(not(target_os = "linux"))]
{
Ok(Value::Bool(false))
}
}
/// is_macos() - Check if running on macOS
/// Returns: Bool
///
/// Example:
/// if is_macos() then "macOS!" else "Not macOS"
fn bi_is_macos() -> Result<Value> {
Ok(Value::Bool(cfg!(target_os = "macos")))
}
/// is_unix() - Check if running on a Unix-like system (Linux, macOS, BSD, etc.)
/// Returns: Bool - true for Linux, macOS, BSD, Android, iOS (Unix-like systems)
///
/// Example:
/// if is_unix() then "/" else "\\" # Path separator
fn bi_is_unix() -> Result<Value> {
Ok(Value::Bool(cfg!(unix)))
}
/// is_bsd() - Check if running on BSD (FreeBSD, OpenBSD, NetBSD)
/// Returns: Bool
///
/// Example:
/// if is_bsd() then "BSD!" else "Not BSD"
fn bi_is_bsd() -> Result<Value> {
let is_bsd = cfg!(target_os = "freebsd")
|| cfg!(target_os = "openbsd")
|| cfg!(target_os = "netbsd")
|| cfg!(target_os = "dragonfly");
Ok(Value::Bool(is_bsd))
}
/// platform_module(base_path) - Get platform-specific module path
/// Args:
/// - base_path: String - Base module path without extension
/// Returns: String - Platform-specific module path
///
/// Given base_path "mymodule", returns:
/// - "mymodule_windows.ish" on Windows
/// - "mymodule_linux.ish" on Linux
/// - "mymodule_macos.ish" on macOS
/// - etc.
///
/// Example:
/// import platform_module("utils") # Imports utils_windows.ish on Windows
fn bi_platform_module(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let base_path = if let Some(input_val) = input {
match input_val {
Value::Str(s) => s,
other => {
return Err(crate::safety::bad_arg(
"platform_module",
"base_path: String",
other.type_name(),
))
}
}
} else if !args.is_empty() {
match &args[0] {
Value::Str(s) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"platform_module",
"base_path: String",
other.type_name(),
))
}
}
} else {
return Err(crate::safety::arg_err(
"platform_module: requires base_path argument",
));
};
use crate::os_tools::OperatingSystem;
let os = OperatingSystem::current();
let platform_suffix = match os {
OperatingSystem::Windows => "windows",
OperatingSystem::Linux => "linux",
OperatingSystem::MacOS => "macos",
OperatingSystem::BSD => "bsd",
OperatingSystem::iOS => "ios",
OperatingSystem::Android => "android",
};
// Strip .ish extension if present
let base = if base_path.ends_with(".ish") {
&base_path[..base_path.len() - 3]
} else {
&base_path
};
let module_path = format!("{}_{}.ish", base, platform_suffix);
Ok(Value::Str(module_path))
}
// --------------- Feature Flag Functions ---------------
/// features() - List all enabled runtime features
/// Returns: Array of String - Names of all enabled features
///
/// Example:
/// features() # Returns ["ai", "experimental"] if those are enabled
fn bi_features() -> Result<Value> {
let features = FEATURE_FLAGS
.read()
.map_err(|_| crate::safety::lock_poisoned("features", "the feature table"))?;
let feature_list: Vec<Value> = features.iter().map(|f| Value::Str(f.clone())).collect();
Ok(Value::Array(feature_list))
}
/// feature_enabled(name) - Check if a runtime feature is enabled
/// Args:
/// - name: String - Feature name to check
/// Returns: Bool - true if the feature is enabled
///
/// Example:
/// feature_enabled("ai") # Returns true if "ai" feature is enabled
/// has_feature("experimental") # Alias
fn bi_feature_enabled(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let name = if let Some(input_val) = input {
match input_val {
Value::Str(s) => s,
other => {
return Err(crate::safety::bad_arg(
"feature_enabled",
"feature name: String",
other.type_name(),
))
}
}
} else if !args.is_empty() {
match &args[0] {
Value::Str(s) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"feature_enabled",
"feature name: String",
other.type_name(),
))
}
}
} else {
return Err(crate::safety::arg_err(
"feature_enabled: requires feature name argument",
));
};
let features = FEATURE_FLAGS
.read()
.map_err(|_| crate::safety::lock_poisoned("feature_enabled", "the feature table"))?;
Ok(Value::Bool(features.contains(&name)))
}
/// feature_enable(name) - Enable a runtime feature
/// Args:
/// - name: String - Feature name to enable
/// Returns: Bool - true (always succeeds)
///
/// Example:
/// feature_enable("experimental")
fn bi_feature_enable(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let name = if let Some(input_val) = input {
match input_val {
Value::Str(s) => s,
other => {
return Err(crate::safety::bad_arg(
"feature_enable",
"feature name: String",
other.type_name(),
))
}
}
} else if !args.is_empty() {
match &args[0] {
Value::Str(s) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"feature_enable",
"feature name: String",
other.type_name(),
))
}
}
} else {
return Err(crate::safety::arg_err(
"feature_enable: requires feature name argument",
));
};
let mut features = FEATURE_FLAGS
.write()
.map_err(|_| crate::safety::lock_poisoned("feature_enable", "the feature table"))?;
features.insert(name);
Ok(Value::Bool(true))
}
/// feature_disable(name) - Disable a runtime feature
/// Args:
/// - name: String - Feature name to disable
/// Returns: Bool - true if the feature was disabled, false if it wasn't enabled
///
/// Example:
/// feature_disable("experimental")
fn bi_feature_disable(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let name = if let Some(input_val) = input {
match input_val {
Value::Str(s) => s,
other => {
return Err(crate::safety::bad_arg(
"feature_disable",
"feature name: String",
other.type_name(),
))
}
}
} else if !args.is_empty() {
match &args[0] {
Value::Str(s) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"feature_disable",
"feature name: String",
other.type_name(),
))
}
}
} else {
return Err(crate::safety::arg_err(
"feature_disable: requires feature name argument",
));
};
let mut features = FEATURE_FLAGS
.write()
.map_err(|_| crate::safety::lock_poisoned("feature_disable", "the feature table"))?;
let removed = features.remove(&name);
Ok(Value::Bool(removed))
}
/// feature_set(name, enabled) - Set a runtime feature to enabled or disabled
/// Args:
/// - name: String - Feature name
/// - enabled: Bool - true to enable, false to disable
/// Returns: Bool - true (always succeeds)
///
/// Example:
/// feature_set("ai", true) # Enable "ai" feature
/// feature_set("debug", false) # Disable "debug" feature
fn bi_feature_set(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "feature_set";
// Piped: the name is the subject and the one argument is `enabled`.
// Direct: name and enabled are both arguments. Surplus arguments are
// refused in either form, not dropped.
let (name_value, enabled_value) = match input {
Some(subject) => match args.as_slice() {
[enabled] => (subject, enabled.clone()),
_ => {
return Err(crate::safety::arg_err(format!(
"{B}: piped, it takes exactly one argument (enabled), got {}",
args.len()
)))
}
},
None => match args.as_slice() {
[name, enabled] => (name.clone(), enabled.clone()),
_ => {
return Err(crate::safety::arg_err(format!(
"{B} requires name and enabled arguments, got {}",
args.len()
)))
}
},
};
let name = match name_value {
Value::Str(s) => s,
other => {
return Err(crate::safety::bad_arg(
B,
"feature name: String",
other.type_name(),
))
}
};
let enabled = match enabled_value {
Value::Bool(b) => b,
other => {
return Err(crate::safety::bad_arg(
B,
"enabled: Bool",
other.type_name(),
))
}
};
// A flag name is a short identifier. Anything else is refused rather than
// stored, where it could never be matched by the feature it was meant for.
if name.is_empty()
|| name.len() > 128
|| !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | ':'))
{
return Err(crate::safety::bad_arg(
B,
"feature name: 1 to 128 letters, digits, '_', '-', '.' or ':'",
&format!("{name:?}"),
));
}
let mut features = FEATURE_FLAGS
.write()
.map_err(|_| crate::safety::lock_poisoned("feature_set", "the feature table"))?;
if enabled {
features.insert(name);
} else {
features.remove(&name);
}
Ok(Value::Bool(true))
}
// ===================== Distributed Computing Builtins =====================
/// cluster_create(name) - Create a new compute cluster
/// Args:
/// - name: String - Cluster name (optional, defaults to "default")
/// Returns: Record with cluster info
fn bi_cluster_create(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
if args.len() > 1 {
return Err(crate::safety::bad_arg(
"cluster_create",
"at most one name argument",
&format!("{} arguments", args.len()),
));
}
// A name that is not a string used to become "default" with no word.
let name = match input.or_else(|| args.first().cloned()) {
None => "default".to_string(),
Some(Value::Str(s)) if !s.is_empty() && s.len() <= 128 => s,
Some(other) => {
return Err(crate::safety::bad_arg(
"cluster_create",
"a cluster name: a non-empty string of at most 128 bytes",
other.type_name(),
))
}
};
// The cluster is stored in this process's registry, where cluster_status
// counts it. Nothing is started and no node is contacted. Nodes added with
// cluster_add_node join a separate process-wide node registry that does not
// record which cluster they belong to.
let created_at = chrono::Utc::now().to_rfc3339();
{
let mut clusters = CLUSTERS
.write()
.map_err(|_| crate::safety::lock_poisoned("cluster_create", "the cluster registry"))?;
if clusters.contains_key(&name) {
return Err(crate::safety::bad_arg(
"cluster_create",
"a cluster name not already in use in this process",
&format!("{name:?}, which is already a cluster"),
));
}
if clusters.len() >= CLUSTER_MAX {
return Err(crate::safety::budget_exceeded(
"cluster_create",
format!("{CLUSTER_MAX} clusters are already stored in this process"),
"this process has no cluster removal; start a new process to reset the registry",
));
}
clusters.insert(name.clone(), created_at.clone());
}
let mut record = BTreeMap::new();
record.insert("name".to_string(), Value::Str(name));
record.insert("status".to_string(), Value::Str("created".to_string()));
record.insert("created_at".to_string(), Value::Str(created_at));
Ok(Value::Record(record))
}
/// Most clusters one process stores. The registry has no removal builtin.
const CLUSTER_MAX: usize = 64;
/// cluster_add_node(id, address, capabilities?) - Add a node to the cluster
/// Args:
/// - id: String - Node identifier
/// - address: String - Node address (e.g., "192.168.1.10:8080")
/// - capabilities: Array - Optional list of capabilities
/// Returns: Record with node info
fn bi_cluster_add_node(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let (id, address) = if let Some(Value::Record(rec)) = input.clone() {
// Record input: {id: "...", address: "..."}
let id = rec
.get("id")
.map(|v| v.to_display_string())
.ok_or_else(|| crate::safety::arg_err("cluster_add_node: missing 'id' in record"))?;
let address = rec
.get("address")
.map(|v| v.to_display_string())
.ok_or_else(|| {
crate::safety::arg_err("cluster_add_node: missing 'address' in record")
})?;
(id, address)
} else if args.len() >= 2 {
let id = args[0].to_display_string();
let address = args[1].to_display_string();
(id, address)
} else {
return Err(crate::safety::arg_err(
"cluster_add_node: requires id and address arguments",
));
};
if args.len() > 3 {
return Err(crate::safety::bad_arg(
"cluster_add_node",
"at most 3 arguments: id, address, capabilities",
&format!("{} arguments", args.len()),
));
}
if id.is_empty() || address.is_empty() || address.chars().any(char::is_whitespace) {
return Err(crate::safety::bad_arg(
"cluster_add_node",
"a non-empty id and an address with no whitespace",
&format!("id '{id}', address '{address}'"),
));
}
let capabilities = match args.get(2) {
None => vec!["compute".to_string(), "storage".to_string()],
// An array of strings or nothing. Anything else used to become an
// empty capability list with no error.
Some(Value::Array(arr)) => {
let mut caps = Vec::with_capacity(arr.len());
for c in arr {
match c {
Value::Str(s) => caps.push(s.clone()),
other => {
return Err(crate::safety::bad_arg(
"cluster_add_node",
"capabilities: Array<String>",
other.type_name(),
))
}
}
}
caps
}
Some(other) => {
return Err(crate::safety::bad_arg(
"cluster_add_node",
"capabilities: Array<String>",
other.type_name(),
))
}
};
let node = ClusterNode {
id: id.clone(),
address: address.clone(),
capabilities: capabilities.clone(),
status: NodeStatus::Registered,
load: 0.0,
last_heartbeat: std::time::Instant::now(),
};
let mut nodes = CLUSTER_NODES
.write()
.map_err(|_| crate::safety::lock_poisoned("cluster_add_node", "the cluster node table"))?;
nodes.insert(id.clone(), node);
let mut record = BTreeMap::new();
record.insert("id".to_string(), Value::Str(id));
record.insert("address".to_string(), Value::Str(address));
record.insert(
"capabilities".to_string(),
Value::Array(capabilities.into_iter().map(Value::Str).collect()),
);
// Nothing contacted the node, so it is not "online": it is registered in
// this process. Liveness is not checked by anything in this builtin.
record.insert("status".to_string(), Value::Str("registered".to_string()));
Ok(Value::Record(record))
}
/// cluster_remove_node(id) - Remove a node from the cluster
fn bi_cluster_remove_node(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let id = input
.or_else(|| args.first().cloned())
.map(|v| key_arg("cluster_remove_node", v))
.transpose()?
.ok_or_else(|| crate::safety::arg_err("cluster_remove_node: requires node id"))?;
let mut nodes = CLUSTER_NODES.write().map_err(|_| {
crate::safety::lock_poisoned("cluster_remove_node", "the cluster node table")
})?;
let removed = nodes.remove(&id).is_some();
Ok(Value::Bool(removed))
}
/// cluster_status() - Get cluster status
fn bi_cluster_status(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let _ = args;
let nodes = CLUSTER_NODES
.read()
.map_err(|_| crate::safety::lock_poisoned("cluster_status", "the cluster node table"))?;
let jobs = DISTRIBUTED_JOBS
.read()
.map_err(|_| crate::safety::lock_poisoned("cluster_status", "the distributed job table"))?;
let clusters = CLUSTERS
.read()
.map_err(|_| crate::safety::lock_poisoned("cluster_status", "the cluster registry"))?;
let online_count = nodes
.values()
.filter(|n| n.status == NodeStatus::Online)
.count();
let busy_count = nodes
.values()
.filter(|n| n.status == NodeStatus::Busy)
.count();
// Nodes added by cluster_add_node are registered, not checked, so they are
// counted here and not as online.
let registered_count = nodes
.values()
.filter(|n| n.status == NodeStatus::Registered)
.count();
let pending_jobs = jobs
.values()
.filter(|j| j.status == JobStatus::Pending)
.count();
let running_jobs = jobs
.values()
.filter(|j| j.status == JobStatus::Running)
.count();
let avg_load: f32 = if nodes.is_empty() {
0.0
} else {
nodes.values().map(|n| n.load).sum::<f32>() / nodes.len() as f32
};
let mut record = BTreeMap::new();
record.insert("clusters".to_string(), Value::Int(clusters.len() as i64));
record.insert(
"cluster_names".to_string(),
Value::Array(clusters.keys().map(|k| Value::Str(k.clone())).collect()),
);
record.insert("total_nodes".to_string(), Value::Int(nodes.len() as i64));
record.insert("online_nodes".to_string(), Value::Int(online_count as i64));
record.insert(
"registered_nodes".to_string(),
Value::Int(registered_count as i64),
);
record.insert("busy_nodes".to_string(), Value::Int(busy_count as i64));
record.insert("total_jobs".to_string(), Value::Int(jobs.len() as i64));
record.insert("pending_jobs".to_string(), Value::Int(pending_jobs as i64));
record.insert("running_jobs".to_string(), Value::Int(running_jobs as i64));
record.insert("average_load".to_string(), Value::Float(avg_load as f64));
Ok(Value::Record(record))
}
/// cluster_nodes() - List all nodes in the cluster
fn bi_cluster_nodes(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let _ = args;
let nodes = CLUSTER_NODES
.read()
.map_err(|_| crate::safety::lock_poisoned("cluster_nodes", "the cluster node table"))?;
let node_list: Vec<Value> = nodes
.values()
.map(|node| {
let mut record = BTreeMap::new();
record.insert("id".to_string(), Value::Str(node.id.clone()));
record.insert("address".to_string(), Value::Str(node.address.clone()));
record.insert(
"status".to_string(),
Value::Str(node.status.as_str().to_string()),
);
record.insert("load".to_string(), Value::Float(node.load as f64));
record.insert(
"capabilities".to_string(),
Value::Array(
node.capabilities
.iter()
.map(|c| Value::Str(c.clone()))
.collect(),
),
);
Value::Record(record)
})
.collect();
Ok(Value::Array(node_list))
}
/// job_submit(name, task, priority?) - Refused. The distributed job table has no
/// executor behind it: a job submitted here would stay pending for ever, and the
/// status it reported would be a promise nothing keeps. No job id is issued, and
/// nothing is run through a shell.
fn bi_job_submit(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped_subject("job_submit", &input)?;
collab_text_arg("job_submit", "name", args.first(), 128)?;
collab_text_arg("job_submit", "task", args.get(1), 4096)?;
match args.get(2) {
None | Some(Value::Int(_)) => {}
Some(other) => {
return Err(crate::safety::bad_arg(
"job_submit",
"priority: Int",
other.type_name(),
))
}
}
Err(crate::safety::unimplemented(
"job_submit",
"there is no job executor, so a submitted job would never run and no job id is issued",
"run the task directly in this session",
))
}
/// job_status(job_id) - The status of a job in the distributed job table.
fn bi_job_status(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let job_id = input
.or_else(|| args.first().cloned())
.map(|v| key_arg("job_status", v))
.transpose()?
.ok_or_else(|| crate::safety::arg_err("job_status: requires job_id"))?;
let jobs = DISTRIBUTED_JOBS
.read()
.map_err(|_| crate::safety::lock_poisoned("job_status", "the distributed job table"))?;
let job = jobs
.get(&job_id)
.ok_or_else(|| crate::safety::not_found("job_status", "job", &job_id.to_string()))?;
let mut record = BTreeMap::new();
record.insert("job_id".to_string(), Value::Str(job.id.clone()));
record.insert("name".to_string(), Value::Str(job.name.clone()));
record.insert(
"status".to_string(),
Value::Str(job.status.as_str().to_string()),
);
record.insert("priority".to_string(), Value::Int(job.priority as i64));
if let Some(node_id) = &job.node_id {
record.insert("node_id".to_string(), Value::Str(node_id.clone()));
}
Ok(Value::Record(record))
}
/// job_cancel(job_id) - Cancel a pending or running job. A job that has already
/// finished or been cancelled is refused, not reported as a silent false.
fn bi_job_cancel(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let job_id = input
.or_else(|| args.first().cloned())
.map(|v| key_arg("job_cancel", v))
.transpose()?
.ok_or_else(|| crate::safety::arg_err("job_cancel: requires job_id"))?;
let mut jobs = DISTRIBUTED_JOBS
.write()
.map_err(|_| crate::safety::lock_poisoned("job_cancel", "the distributed job table"))?;
let job = jobs
.get_mut(&job_id)
.ok_or_else(|| crate::safety::not_found("job_cancel", "job", &job_id.to_string()))?;
match job.status {
JobStatus::Pending | JobStatus::Running => {
job.status = JobStatus::Cancelled;
Ok(Value::Bool(true))
}
_ => Err(crate::safety::bad_state(
"job_cancel",
&format!(
"job {job_id} is {}; only a pending or running job cancels",
job.status.as_str()
),
"job_status",
)),
}
}
/// job_results(job_id) - Get the results of a completed job
fn bi_job_results(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let job_id = input
.or_else(|| args.first().cloned())
.map(|v| key_arg("job_results", v))
.transpose()?
.ok_or_else(|| crate::safety::arg_err("job_results: requires job_id"))?;
let results = JOB_RESULTS
.read()
.map_err(|_| crate::safety::lock_poisoned("job_results", "the job result store"))?;
if let Some(result_values) = results.get(&job_id) {
Ok(Value::Array(result_values.clone()))
} else {
Ok(Value::Array(vec![]))
}
}
/// job_list() - List all jobs
fn bi_job_list(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let _ = args;
let jobs = DISTRIBUTED_JOBS
.read()
.map_err(|_| crate::safety::lock_poisoned("job_list", "the distributed job table"))?;
let job_list: Vec<Value> = jobs
.values()
.map(|job| {
let mut record = BTreeMap::new();
record.insert("job_id".to_string(), Value::Str(job.id.clone()));
record.insert("name".to_string(), Value::Str(job.name.clone()));
record.insert(
"status".to_string(),
Value::Str(job.status.as_str().to_string()),
);
record.insert("priority".to_string(), Value::Int(job.priority as i64));
Value::Record(record)
})
.collect();
Ok(Value::Array(job_list))
}
/// remote_exec(node_id, command), or the same two fields as a Record piped in:
/// not implemented. No SSH or cluster transport is wired, so the command is not
/// sent to the node. Once it is, the remote node runs `command` through its
/// login shell, because ssh hands the string to the remote shell; a caller must
/// treat `command` as shell input. Arguments are checked first: a plain node
/// name, a non-empty command of at most 8 KiB, and a node that is registered.
fn bi_remote_exec(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "remote_exec";
let (node, command) = match input {
Some(Value::Record(rec)) => {
if !args.is_empty() {
return Err(crate::safety::arg_err(
"remote_exec: give the node and command as arguments or as a piped Record, not both",
));
}
for key in rec.keys() {
if key != "node_id" && key != "command" {
return Err(crate::safety::arg_err(format!(
"remote_exec: unknown field {key:?}; the fields are node_id and command"
)));
}
}
let text = |field: &str| -> Result<String> {
match rec.get(field) {
Some(Value::Str(s)) => Ok(s.clone()),
Some(other) => Err(crate::safety::arg_err(format!(
"remote_exec: {field} must be a String, got {}",
other.type_name()
))),
None => Err(crate::safety::arg_err(format!(
"remote_exec: missing '{field}'"
))),
}
};
(text("node_id")?, text("command")?)
}
Some(other) => {
return Err(crate::safety::arg_err(format!(
"remote_exec: a piped input must be a Record with node_id and command, got {}",
other.type_name()
)))
}
None => {
k8s_max_args(B, &args, 2)?;
let node = k8s_need(B, "a node id", &args, 0, pkg_plain)?;
let command = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
Some(other) => {
return Err(crate::safety::arg_err(format!(
"remote_exec: command must be a String, got {}",
other.type_name()
)))
}
None => {
return Err(crate::safety::arg_err(
"remote_exec requires node_id and command",
))
}
};
(node, command)
}
};
let node_id = pkg_plain(B, "node id", &node)?;
if command.trim().is_empty() || command.contains('\0') || command.len() > OPS_MAX_COMMAND {
return Err(crate::safety::arg_err(format!(
"remote_exec: command must be non-empty, without NUL, and at most {OPS_MAX_COMMAND} bytes"
)));
}
let known = CLUSTER_NODES
.read()
.map_err(|_| crate::safety::lock_poisoned("remote_exec", "the cluster node table"))?
.contains_key(&node_id);
if !known {
return Err(crate::safety::not_found(B, "node", &node_id));
}
Err(crate::safety::unimplemented(
B,
&format!(
"the command was not sent to node {node_id:?}: no SSH or cluster transport is wired"
),
"use ssh_exec for a remote command",
))
}
/// aggregate_results(results, strategy?) - Aggregate results from distributed computation
fn bi_aggregate_results(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
// Piped: the results are the input and the strategy is the only argument.
// Unpiped: results, then strategy. The strategy used to be read from
// position 1 either way, so `[..] | aggregate_results("sum")` silently
// aggregated with "concat".
let (results, strategy_arg) = match input {
Some(v) => {
if args.len() > 1 {
return Err(crate::safety::bad_arg(
"aggregate_results",
"with piped input, at most one argument: the strategy",
&format!("{} arguments", args.len()),
));
}
(v, args.first())
}
None => {
if args.len() > 2 {
return Err(crate::safety::bad_arg(
"aggregate_results",
"results, then an optional strategy",
&format!("{} arguments", args.len()),
));
}
let r = args.first().cloned().ok_or_else(|| {
crate::safety::arg_err("aggregate_results: requires results array")
})?;
(r, args.get(1))
}
};
let results = match results {
Value::Array(arr) => arr,
other => {
return Err(crate::safety::bad_arg(
"aggregate_results",
"results: Array",
other.type_name(),
))
}
};
let strategy = match strategy_arg {
None => "concat".to_string(),
Some(Value::Str(s)) => s.clone(),
Some(other) => {
return Err(crate::safety::bad_arg(
"aggregate_results",
"strategy: a string (concat, sum, count, first, last)",
other.type_name(),
))
}
};
match strategy.as_str() {
"concat" => Ok(Value::Array(results)),
"sum" => {
// A non-number used to be skipped, so the sum of a list with a
// failed element looked like a sum of the rest. Refuse it.
let mut sum = 0.0f64;
for (i, v) in results.iter().enumerate() {
match v {
Value::Int(n) => sum += *n as f64,
Value::Float(f) if f.is_finite() => sum += *f,
other => {
return Err(crate::safety::bad_arg(
"aggregate_results",
&format!("results[{i}]: a finite number for strategy \"sum\""),
other.type_name(),
))
}
}
}
Ok(Value::Float(sum))
}
"count" => Ok(Value::Int(results.len() as i64)),
"first" | "last" => {
let pick = if strategy == "first" {
results.first()
} else {
results.last()
};
// An empty list has no first or last element: that is a failure,
// not a Null answer.
pick.cloned().ok_or_else(|| {
crate::safety::bad_arg(
"aggregate_results",
&format!("a non-empty results array for strategy \"{strategy}\""),
"an empty array",
)
})
}
other => Err(crate::safety::bad_arg(
"aggregate_results",
"strategy: one of concat, sum, count, first, last",
other,
)),
}
}
// ===================== Advanced AI Builtins =====================
/// rag_index(content, source?, metadata?) - Index a document for RAG
fn bi_rag_index(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
// The index lives for the whole process and every document is kept, so
// both the size of one document and the number held are bounded.
const MAX_DOC_BYTES: usize = 1 << 20;
const MAX_DOCS: usize = 10_000;
// Piped, the content is the input and `args` are source and metadata.
// Unpiped, `args` are content, source and metadata. Reading source from
// `args[1]` either way dropped a piped source without a word.
let (content_in, rest): (Option<Value>, &[Value]) = match input {
Some(v) => (Some(v), &args[..]),
None => (args.first().cloned(), args.get(1..).unwrap_or(&[])),
};
if rest.len() > 2 {
return Err(crate::safety::bad_arg(
"rag_index",
"at most 3 arguments: content, source, metadata",
&format!("{} arguments", args.len()),
));
}
let content = match content_in {
Some(Value::Str(s)) if s.len() <= MAX_DOC_BYTES => s,
Some(Value::Str(s)) => {
return Err(crate::safety::budget_exceeded(
"rag_index",
format!("content is {} bytes; the limit is {MAX_DOC_BYTES}", s.len()),
"index a smaller document or split it",
))
}
Some(other) => {
return Err(crate::safety::bad_arg(
"rag_index",
"content: a string",
other.type_name(),
))
}
None => return Err(crate::safety::arg_err("rag_index: requires content")),
};
let source = match rest.first() {
None | Some(Value::Null) => "unknown".to_string(),
Some(Value::Str(s)) => s.clone(),
Some(other) => {
return Err(crate::safety::bad_arg(
"rag_index",
"source: a string",
other.type_name(),
))
}
};
let metadata: HashMap<String, String> = match rest.get(1) {
None | Some(Value::Null) => HashMap::new(),
Some(Value::Record(rec)) => rec
.iter()
.map(|(k, v)| (k.clone(), v.to_display_string()))
.collect(),
Some(other) => {
return Err(crate::safety::bad_arg(
"rag_index",
"metadata: a Record",
other.type_name(),
))
}
};
let mut index = RAG_INDEX
.write()
.map_err(|_| crate::safety::lock_poisoned("rag_index", "the RAG index"))?;
if index.chunks.len() >= MAX_DOCS {
return Err(crate::safety::budget_exceeded(
"rag_index",
format!("the index already holds {MAX_DOCS} documents"),
"the index is process-local and has no removal; start a new process",
));
}
let doc_id = index.add_document(&content, &source, metadata);
let mut record = BTreeMap::new();
record.insert("doc_id".to_string(), Value::Str(doc_id));
record.insert("source".to_string(), Value::Str(source));
record.insert("chunk_count".to_string(), Value::Int(1));
record.insert(
"total_docs".to_string(),
Value::Int(index.chunks.len() as i64),
);
Ok(Value::Record(record))
}
/// Most chunks rag_search returns, and most rag_query gathers into its context.
const RAG_SEARCH_MAX_TOP_K: usize = 100;
const RAG_QUERY_MAX_TOP_K: usize = 20;
/// The query and `top_k` of a rag call. Piped, the query is the input and
/// `args` holds only `top_k`; called directly, `args` is `query, top_k`. The
/// old code read `top_k` from `args[1]` either way, so a piped count was
/// ignored. `top_k` must be an integer from 1 up to `ceiling`: a negative
/// count used to wrap through `as usize`, and anything else fell back to the
/// default without a word.
fn rag_query_top_k(
builtin: &str,
args: &[Value],
input: Option<Value>,
default: usize,
ceiling: usize,
) -> Result<(String, usize)> {
let (query_v, rest): (Value, &[Value]) = match input {
Some(v) => (v, args),
None => match args.split_first() {
Some((first, rest)) => (first.clone(), rest),
None => return Err(crate::safety::arg_err(format!("{builtin}: requires query"))),
},
};
if rest.len() > 1 {
return Err(crate::safety::bad_arg(
builtin,
"at most 2 arguments when called directly (query, top_k); 1 when piped (top_k)",
&format!("{} arguments", args.len()),
));
}
let query = key_arg(builtin, query_v)?;
let top_k = match rest.first() {
None | Some(Value::Null) => default,
Some(Value::Int(n)) if *n < 1 => {
return Err(crate::safety::bad_arg(
builtin,
"top_k: an integer from 1 up",
&n.to_string(),
))
}
Some(Value::Int(n)) if *n as u128 > ceiling as u128 => {
return Err(crate::safety::budget_exceeded(
builtin,
format!("top_k {n} is above the limit {ceiling}"),
"ask for fewer chunks",
))
}
Some(Value::Int(n)) => *n as usize,
Some(other) => {
return Err(crate::safety::bad_arg(
builtin,
"top_k: an integer",
other.type_name(),
))
}
};
Ok((query, top_k))
}
/// rag_search(query, top_k?) - Search indexed documents
fn bi_rag_search(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let (query, top_k) = rag_query_top_k("rag_search", &args, input, 5, RAG_SEARCH_MAX_TOP_K)?;
let index = RAG_INDEX
.read()
.map_err(|_| crate::safety::lock_poisoned("rag_search", "the RAG index"))?;
let results = index.search(&query, top_k);
let result_values: Vec<Value> = results
.iter()
.map(|(chunk, score)| {
let mut record = BTreeMap::new();
record.insert("doc_id".to_string(), Value::Str(chunk.id.clone()));
record.insert("content".to_string(), Value::Str(chunk.content.clone()));
record.insert("source".to_string(), Value::Str(chunk.source.clone()));
record.insert("score".to_string(), Value::Float(*score as f64));
Value::Record(record)
})
.collect();
Ok(Value::Array(result_values))
}
/// rag_query(query, top_k?) - Search and generate a response using RAG
fn bi_rag_query(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let (query, top_k) = rag_query_top_k("rag_query", &args, input, 3, RAG_QUERY_MAX_TOP_K)?;
let index = RAG_INDEX
.read()
.map_err(|_| crate::safety::lock_poisoned("rag_query", "the RAG index"))?;
let results = index.search(&query, top_k);
// Build context from retrieved documents
let context: String = results
.iter()
.map(|(chunk, _)| chunk.content.as_str())
.collect::<Vec<_>>()
.join("\n\n");
let mut record = BTreeMap::new();
record.insert("query".to_string(), Value::Str(query));
record.insert("context".to_string(), Value::Str(context));
record.insert(
"sources".to_string(),
Value::Array(
results
.iter()
.map(|(chunk, _)| Value::Str(chunk.source.clone()))
.collect(),
),
);
record.insert("doc_count".to_string(), Value::Int(results.len() as i64));
Ok(Value::Record(record))
}
/// rag_clear() - Clear the RAG index
fn bi_rag_clear(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let _ = args;
let mut index = RAG_INDEX
.write()
.map_err(|_| crate::safety::lock_poisoned("rag_clear", "the RAG index"))?;
let count = index.chunks.len();
index.clear();
Ok(Value::Int(count as i64))
}
/// The optional properties record of a `kg_*` call: absent or null is empty,
/// a Record is taken, and anything else is refused rather than dropped.
fn kg_properties(builtin: &str, v: Option<&Value>) -> Result<HashMap<String, String>> {
match v {
None | Some(Value::Null) => Ok(HashMap::new()),
Some(Value::Record(rec)) => Ok(rec
.iter()
.map(|(k, v)| (k.clone(), v.to_display_string()))
.collect()),
Some(other) => Err(crate::safety::bad_arg(
builtin,
"properties: a Record",
other.type_name(),
)),
}
}
/// kg_add(type, name, properties?) - Add an entity to the knowledge graph
fn bi_kg_add(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const MAX_ENTITIES: usize = 100_000;
if args.len() > 3 {
return Err(crate::safety::bad_arg(
"kg_add",
"at most 3 arguments: type, name, properties",
&format!("{} arguments", args.len()),
));
}
// Only a Record is documented as piped input; anything else was dropped.
if matches!(&input, Some(v) if !matches!(v, Value::Record(_))) {
return Err(crate::safety::bad_arg(
"kg_add",
"piped input: a Record {type, name, properties?}",
input.as_ref().map_or("nothing", |v| v.type_name()),
));
}
let (entity_type, name) = if let Some(Value::Record(rec)) = input {
let entity_type = rec
.get("type")
.map(|v| v.to_display_string())
.ok_or_else(|| crate::safety::arg_err("kg_add: missing 'type'"))?;
let name = rec
.get("name")
.map(|v| v.to_display_string())
.ok_or_else(|| crate::safety::arg_err("kg_add: missing 'name'"))?;
(entity_type, name)
} else if args.len() >= 2 {
(args[0].to_display_string(), args[1].to_display_string())
} else {
return Err(crate::safety::arg_err("kg_add: requires type and name"));
};
let properties = kg_properties("kg_add", args.get(2))?;
let mut kg = KNOWLEDGE_GRAPH
.write()
.map_err(|_| crate::safety::lock_poisoned("kg_add", "the knowledge graph"))?;
if kg.entities.len() >= MAX_ENTITIES {
return Err(crate::safety::budget_exceeded(
"kg_add",
format!("the knowledge graph already holds {MAX_ENTITIES} entities"),
"the graph is process-local and has no removal; start a new process",
));
}
let entity_id = kg.add_entity(&entity_type, &name, properties);
let mut record = BTreeMap::new();
record.insert("entity_id".to_string(), Value::Str(entity_id));
record.insert("type".to_string(), Value::Str(entity_type));
record.insert("name".to_string(), Value::Str(name));
Ok(Value::Record(record))
}
/// kg_relate(source_id, target_id, relation_type, properties?) - Add a relation
fn bi_kg_relate(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
if args.len() > 4 {
return Err(crate::safety::bad_arg(
"kg_relate",
"at most 4 arguments: source, target, relation, properties",
&format!("{} arguments", args.len()),
));
}
if matches!(&input, Some(v) if !matches!(v, Value::Record(_))) {
return Err(crate::safety::bad_arg(
"kg_relate",
"piped input: a Record {source, target, relation, properties?}",
input.as_ref().map_or("nothing", |v| v.type_name()),
));
}
let (source_id, target_id, relation_type) = if let Some(Value::Record(rec)) = input {
let source = rec
.get("source")
.map(|v| v.to_display_string())
.ok_or_else(|| crate::safety::arg_err("kg_relate: missing 'source'"))?;
let target = rec
.get("target")
.map(|v| v.to_display_string())
.ok_or_else(|| crate::safety::arg_err("kg_relate: missing 'target'"))?;
let rel_type = rec
.get("relation")
.map(|v| v.to_display_string())
.ok_or_else(|| crate::safety::arg_err("kg_relate: missing 'relation'"))?;
(source, target, rel_type)
} else if args.len() >= 3 {
(
args[0].to_display_string(),
args[1].to_display_string(),
args[2].to_display_string(),
)
} else {
return Err(crate::safety::arg_err(
"kg_relate: requires source_id, target_id, and relation_type",
));
};
let properties = kg_properties("kg_relate", args.get(3))?;
let mut kg = KNOWLEDGE_GRAPH
.write()
.map_err(|_| crate::safety::lock_poisoned("kg_relate", "the knowledge graph"))?;
let relation_id = kg.add_relation(&source_id, &target_id, &relation_type, properties)?;
let mut record = BTreeMap::new();
record.insert("relation_id".to_string(), Value::Str(relation_id));
record.insert("source".to_string(), Value::Str(source_id));
record.insert("target".to_string(), Value::Str(target_id));
record.insert("relation_type".to_string(), Value::Str(relation_type));
Ok(Value::Record(record))
}
/// kg_query(pattern) - Query the knowledge graph
fn bi_kg_query(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let pattern = input
.or_else(|| args.first().cloned())
.map(|v| key_arg("kg_query", v))
.transpose()?
.ok_or_else(|| crate::safety::arg_err("kg_query: requires pattern"))?;
let kg = KNOWLEDGE_GRAPH
.read()
.map_err(|_| crate::safety::lock_poisoned("kg_query", "the knowledge graph"))?;
let results = kg.query(&pattern);
Ok(Value::Array(results))
}
/// kg_entities() - List all entities in the knowledge graph
fn bi_kg_entities(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let _ = args;
let kg = KNOWLEDGE_GRAPH
.read()
.map_err(|_| crate::safety::lock_poisoned("kg_entities", "the knowledge graph"))?;
let entities: Vec<Value> = kg
.entities
.values()
.map(|entity| {
let mut record = BTreeMap::new();
record.insert("id".to_string(), Value::Str(entity.id.clone()));
record.insert("type".to_string(), Value::Str(entity.entity_type.clone()));
record.insert("name".to_string(), Value::Str(entity.name.clone()));
Value::Record(record)
})
.collect();
Ok(Value::Array(entities))
}
/// kg_relations() - List all relations in the knowledge graph
fn bi_kg_relations(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let _ = args;
let kg = KNOWLEDGE_GRAPH
.read()
.map_err(|_| crate::safety::lock_poisoned("kg_relations", "the knowledge graph"))?;
let relations: Vec<Value> = kg
.relations
.iter()
.map(|rel| {
let mut record = BTreeMap::new();
record.insert("id".to_string(), Value::Str(rel.id.clone()));
record.insert("source".to_string(), Value::Str(rel.source_id.clone()));
record.insert("target".to_string(), Value::Str(rel.target_id.clone()));
record.insert("type".to_string(), Value::Str(rel.relation_type.clone()));
Value::Record(record)
})
.collect();
Ok(Value::Array(relations))
}
/// semantic_cache(query, response) - Store a response in the semantic cache
fn bi_semantic_cache(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const MAX_QUERY_BYTES: usize = 4096;
const MAX_RESPONSE_BYTES: usize = 256 * 1024;
if args.len() > 2 {
return Err(crate::safety::bad_arg(
"semantic_cache",
"at most 2 arguments: query, response",
&format!("{} arguments", args.len()),
));
}
if matches!(&input, Some(v) if !matches!(v, Value::Record(_))) {
return Err(crate::safety::bad_arg(
"semantic_cache",
"piped input: a Record {query, response}",
input.as_ref().map_or("nothing", |v| v.type_name()),
));
}
let (query, response) = if let Some(Value::Record(rec)) = input {
let query = rec
.get("query")
.map(|v| v.to_display_string())
.ok_or_else(|| crate::safety::arg_err("semantic_cache: missing 'query'"))?;
let response = rec
.get("response")
.map(|v| v.to_display_string())
.ok_or_else(|| crate::safety::arg_err("semantic_cache: missing 'response'"))?;
(query, response)
} else if args.len() >= 2 {
(args[0].to_display_string(), args[1].to_display_string())
} else {
return Err(crate::safety::arg_err(
"semantic_cache: requires query and response",
));
};
if query.is_empty() || query.len() > MAX_QUERY_BYTES {
return Err(crate::safety::bad_arg(
"semantic_cache",
&format!("a query of 1 to {MAX_QUERY_BYTES} bytes"),
&format!("{} bytes", query.len()),
));
}
if response.len() > MAX_RESPONSE_BYTES {
return Err(crate::safety::budget_exceeded(
"semantic_cache",
format!(
"response is {} bytes; the limit is {MAX_RESPONSE_BYTES}",
response.len()
),
"cache a shorter response",
));
}
let mut cache = SEMANTIC_CACHE
.write()
.map_err(|_| crate::safety::lock_poisoned("semantic_cache", "the semantic cache"))?;
cache.put(&query, &response);
let (entries, hits) = cache.stats();
let mut record = BTreeMap::new();
record.insert("cached".to_string(), Value::Bool(true));
record.insert("total_entries".to_string(), Value::Int(entries as i64));
record.insert("total_hits".to_string(), Value::Int(hits as i64));
Ok(Value::Record(record))
}
/// semantic_cache_get(query) - Try to get a cached response
fn bi_semantic_cache_get(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let query = input
.or_else(|| args.first().cloned())
.map(|v| key_arg("semantic_cache_get", v))
.transpose()?
.ok_or_else(|| crate::safety::arg_err("semantic_cache_get: requires query"))?;
let mut cache = SEMANTIC_CACHE
.write()
.map_err(|_| crate::safety::lock_poisoned("semantic_cache_get", "the semantic cache"))?;
if let Some(response) = cache.get(&query) {
let mut record = BTreeMap::new();
record.insert("hit".to_string(), Value::Bool(true));
record.insert("response".to_string(), Value::Str(response));
Ok(Value::Record(record))
} else {
let mut record = BTreeMap::new();
record.insert("hit".to_string(), Value::Bool(false));
Ok(Value::Record(record))
}
}
/// semantic_cache_clear() - Clear the semantic cache
fn bi_semantic_cache_clear(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let _ = args;
let mut cache = SEMANTIC_CACHE
.write()
.map_err(|_| crate::safety::lock_poisoned("semantic_cache_clear", "the semantic cache"))?;
let (count, _) = cache.stats();
cache.clear();
Ok(Value::Int(count as i64))
}
// --------------- AI / Agents wrappers (optional) ---------------
/// The most steps one agent or swarm run may take. Each step can be a model
/// call, so an unbounded `max_steps` is an unbounded bill.
const MAX_AGENT_STEPS: i64 = 1000;
/// The keys an agent or swarm config record may carry. Any other key is
/// refused: the body used to ignore it, so a caller who set `model_uri` got a
/// run on whatever model the environment picked, with no error.
const AGENT_CONFIG_KEYS: &[&str] = &["goal", "tools", "max_steps", "dry_run"];
/// One agent or swarm call, with every argument checked before anything runs.
struct AgentCall {
goal: String,
tools: Vec<String>,
max_steps: usize,
dry_run: bool,
}
/// Read the arguments of `agent(...)` or `swarm(...)` in each of their forms:
///
/// - a config record, as the argument or as the piped input;
/// - `goal, [tools...], [max_steps], [dry_run]`, with the tools as Strings or
/// as one Array of them;
/// - `goal, json`, where `json` is a JSON object whose keys are the config.
///
/// Every argument is either used or refused. A surplus argument, or one of the
/// wrong type, used to be dropped and the run went ahead without it. Nothing
/// here calls a model, so a refusal costs no rate-limit budget and no tokens.
fn parse_agent_call(
builtin: &str,
args: &[Value],
input: Option<&Value>,
default_steps: usize,
) -> Result<AgentCall> {
if let Some(piped) = input {
return match piped {
Value::Record(cfg) if args.is_empty() => {
agent_config(builtin, cfg.clone(), default_steps)
}
Value::Record(_) => Err(crate::safety::arg_err(format!(
"{builtin}: a piped config takes no other arguments"
))),
other => Err(crate::safety::arg_err(format!(
"{builtin}: piped input must be a config record, got {}",
other.type_name()
))),
};
}
let goal_raw = match args.first() {
Some(Value::Record(cfg)) => {
if args.len() > 1 {
return Err(surplus_arg(builtin, &args[1]));
}
return agent_config(builtin, cfg.clone(), default_steps);
}
Some(Value::Str(s)) => s.clone(),
Some(other) => {
return Err(crate::safety::bad_arg(
builtin,
"a goal String, or a config Record",
other.type_name(),
))
}
None => return Err(crate::safety::bad_arg(builtin, "a goal String", "nothing")),
};
// A JSON object as the second argument is a whole config; the goal is the
// first argument.
if let Some(Value::Str(s)) = args.get(1) {
let text = s.trim();
if text.starts_with('{') {
let json: serde_json::Value = serde_json::from_str(text).map_err(|e| {
crate::safety::arg_err(format!("{builtin}: config JSON does not parse: {e}"))
})?;
let mut cfg = match Value::from_json(&json) {
Value::Record(cfg) => cfg,
_ => {
return Err(crate::safety::arg_err(format!(
"{builtin}: config JSON must be an object"
)))
}
};
cfg.insert("goal".into(), Value::Str(goal_raw));
// Positional max_steps and dry_run may follow, as the form
// `goal, json, [max_steps], [dry_run]` documents; they override the config.
let mut j = 2usize;
if let Some(Value::Int(n)) = args.get(j) {
cfg.insert("max_steps".into(), Value::Int(*n));
j += 1;
}
if let Some(Value::Bool(b)) = args.get(j) {
cfg.insert("dry_run".into(), Value::Bool(*b));
j += 1;
}
if let Some(extra) = args.get(j) {
return Err(surplus_arg(builtin, extra));
}
return agent_config(builtin, cfg, default_steps);
}
}
let goal =
validate_ai_prompt(&goal_raw).context(format!("{builtin}: goal validation failed"))?;
let mut i = 1usize;
let mut tools: Vec<String> = Vec::new();
while let Some(Value::Str(name)) = args.get(i) {
tools.push(name.clone());
i += 1;
}
if tools.is_empty() {
if let Some(Value::Array(items)) = args.get(i) {
tools = agent_strings(builtin, items, "tools array must contain strings")?;
i += 1;
}
}
let mut max_steps = default_steps;
if let Some(Value::Int(n)) = args.get(i) {
max_steps = steps_arg(builtin, *n)?;
i += 1;
}
let mut dry_run = false;
if let Some(Value::Bool(b)) = args.get(i) {
dry_run = *b;
i += 1;
}
if let Some(extra) = args.get(i) {
return Err(surplus_arg(builtin, extra));
}
check_tools(builtin, &tools)?;
Ok(AgentCall {
goal,
tools,
max_steps,
dry_run,
})
}
/// Read a config record: `goal` is required; `tools`, `max_steps` and `dry_run`
/// are optional and each is type-checked rather than defaulted when wrong.
fn agent_config(
builtin: &str,
cfg: BTreeMap<String, Value>,
default_steps: usize,
) -> Result<AgentCall> {
if let Some(key) = cfg
.keys()
.find(|k| !AGENT_CONFIG_KEYS.contains(&k.as_str()))
{
return Err(crate::safety::arg_err(format!(
"{builtin} config has no key '{key}'; its keys are goal, tools, max_steps, dry_run"
)));
}
let goal_raw = match cfg.get("goal") {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(crate::safety::arg_err(format!(
"{builtin} config requires {{goal: String}}"
)))
}
};
let goal =
validate_ai_prompt(&goal_raw).context(format!("{builtin}: goal validation failed"))?;
let tools = match cfg.get("tools") {
None => Vec::new(),
Some(Value::Array(items)) => {
agent_strings(builtin, items, "config tools must be an array of strings")?
}
Some(other) => {
return Err(crate::safety::bad_arg(
builtin,
"tools: Array of String",
other.type_name(),
))
}
};
let max_steps = match cfg.get("max_steps") {
None => default_steps,
Some(Value::Int(n)) => steps_arg(builtin, *n)?,
Some(other) => {
return Err(crate::safety::bad_arg(
builtin,
"max_steps: Int",
other.type_name(),
))
}
};
let dry_run = match cfg.get("dry_run") {
None => false,
Some(Value::Bool(b)) => *b,
Some(other) => {
return Err(crate::safety::bad_arg(
builtin,
"dry_run: Bool",
other.type_name(),
))
}
};
check_tools(builtin, &tools)?;
Ok(AgentCall {
goal,
tools,
max_steps,
dry_run,
})
}
fn agent_strings(builtin: &str, items: &[Value], message: &str) -> Result<Vec<String>> {
items
.iter()
.map(|v| match v {
Value::Str(s) => Ok(s.clone()),
_ => Err(crate::safety::arg_err(format!("{builtin}: {message}"))),
})
.collect()
}
fn steps_arg(builtin: &str, n: i64) -> Result<usize> {
if (1..=MAX_AGENT_STEPS).contains(&n) {
Ok(n as usize)
} else {
Err(crate::safety::bad_arg(
builtin,
&format!("max_steps: Int in 1..={MAX_AGENT_STEPS}"),
&n.to_string(),
))
}
}
/// Refuse a tool name no builtin answers to. The agent would otherwise learn
/// this only when the model asked for that tool, after the model had run.
fn check_tools(builtin: &str, tools: &[String]) -> Result<()> {
for tool in tools {
if !crate::builtins::is_dispatched(tool) {
return Err(crate::safety::not_found(builtin, "tool", tool));
}
}
Ok(())
}
fn surplus_arg(builtin: &str, extra: &Value) -> anyhow::Error {
crate::safety::arg_err(format!(
"{builtin}: unexpected {} argument; the form is goal, [tools...], [max_steps], [dry_run]",
extra.type_name()
))
}
/// Refuse a piped value for a builtin that takes none, so it is never dropped
/// without a word.
fn refuse_pipeline_input(builtin: &str, input: &Option<Value>) -> Result<()> {
if input.is_some() {
return Err(crate::safety::arg_err(format!(
"{builtin} takes no pipeline input"
)));
}
Ok(())
}
fn bi_agent(args: Vec<Value>, input: Option<Value>, env: &mut Env) -> Result<Value> {
let call = parse_agent_call("agent", &args, input.as_ref(), 8)?;
// SECURITY: Rate limit agent calls (CVSS 7.8). This runs after the
// arguments are read, so a refused call does not spend the budget.
check_rate_limit("bi_agent", 10, Duration::from_secs(60))
.context("Agent rate limit exceeded")?;
let tools: Vec<&str> = call.tools.iter().map(String::as_str).collect();
let out = crate::ai::agents::run_sync(&call.goal, &tools, call.max_steps, call.dry_run, env)?;
Ok(Value::Str(out))
}
fn bi_swarm(args: Vec<Value>, input: Option<Value>, env: &mut Env) -> Result<Value> {
let call = parse_agent_call("swarm", &args, input.as_ref(), 12)?;
let tools: Vec<&str> = call.tools.iter().map(String::as_str).collect();
let out =
crate::ai::agents::swarm::run_sync(&call.goal, &tools, call.max_steps, call.dry_run, env)?;
Ok(Value::Str(out))
}
// --------------- File system commands ---------------
fn bi_ls(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let path_str = if args.is_empty() {
".".to_string()
} else {
match &args[0] {
Value::Str(s) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"ls",
"path: String",
other.type_name(),
))
}
}
};
// The probes below stat the string, which opens SMB for a UNC one.
crate::security::check_share_egress("ls", &path_str)?;
// `ls("src/*.rs")` lists the matches themselves, as `ls src/*.rs` does.
let paths: Vec<std::path::PathBuf> =
if crate::posix::has_glob_meta(&path_str) && !std::path::Path::new(&path_str).exists() {
expand_glob_for("ls", &path_str)?
.into_iter()
.map(std::path::PathBuf::from)
.collect()
} else {
if !std::path::Path::new(&path_str).exists() {
return Err(crate::safety::not_found("ls", "directory", &path_str));
}
// SECURITY: Validate path to prevent traversal attacks (CVSS 8.2)
let validated_path = validate_read_path(&path_str)?;
fs::read_dir(&validated_path)
.map_err(|e| crate::safety::fs_error("ls", &validated_path, &e))?
.map(|e| e.map(|e| e.path()))
.collect::<std::io::Result<Vec<_>>>()
.map_err(|e| crate::safety::fs_error("ls", &validated_path, &e))?
};
let mut files = Vec::new();
for path in paths {
// A dangling symlink is listed as itself, as ls(1) lists it; it used to
// fail the whole directory with E_NOT_FOUND.
let metadata = fs::metadata(&path)
.or_else(|e| match fs::symlink_metadata(&path) {
Ok(link) if link.file_type().is_symlink() => Ok(link),
_ => Err(e),
})
.map_err(|e| crate::safety::fs_error("ls", &path, &e))?;
let name = path
.file_name()
.ok_or_else(|| crate::safety::arg_err("ls: invalid filename"))?
.to_string_lossy()
.to_string();
// Extract file extension
let ext = path
.extension()
.map(|e| format!(".{}", e.to_string_lossy()))
.unwrap_or_default();
let mut record = BTreeMap::new();
record.insert("name".to_string(), Value::Str(name));
record.insert(
"path".to_string(),
Value::Str(path.to_string_lossy().to_string()),
);
record.insert("ext".to_string(), Value::Str(ext));
record.insert("is_dir".to_string(), Value::Bool(metadata.is_dir()));
record.insert("size".to_string(), Value::Int(metadata.len() as i64));
if let Ok(modified) = metadata.modified() {
if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
record.insert(
"modified".to_string(),
Value::Int(duration.as_secs() as i64),
);
}
}
files.push(Value::Record(record));
}
Ok(Value::Array(files))
}
fn bi_pwd() -> Result<Value> {
let current_dir = std::env::current_dir()?;
Ok(Value::Str(current_dir.to_string_lossy().to_string()))
}
fn bi_cat(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
if let Some(input) = input {
return Ok(input);
}
if args.is_empty() {
return Err(crate::safety::arg_err("cat: no file specified"));
}
// The probes below stat the string, which opens SMB for a UNC one.
for arg in &args {
if let Value::Str(path) = arg {
crate::security::check_share_egress("cat", path)?;
}
}
let path_str = match &args[0] {
Value::Str(s) => s,
other => {
return Err(crate::safety::bad_arg(
"cat",
"path: String",
other.type_name(),
))
}
};
// A glob reads every match, in order, as `cat src/*.rs` does -- unless a
// file with that literal name exists.
if crate::posix::has_glob_meta(path_str) && !std::path::Path::new(path_str).exists() {
let paths = expand_glob_for("cat", path_str)?;
let files: Vec<&String> = paths
.iter()
.filter(|p| std::path::Path::new(p).is_file())
.collect();
if files.is_empty() {
return Err(crate::safety::not_found("cat", "file matching", path_str));
}
let mut all = String::new();
for p in files {
all.push_str(&cat_one(p)?);
}
return Ok(Value::Str(all));
}
Ok(Value::Str(cat_one(path_str)?))
}
fn cat_one(path_str: &str) -> Result<String> {
// A missing parent directory made validate_read_path fail with an uncoded
// "Failed to canonicalize parent directory".
if !std::path::Path::new(path_str).exists() {
return Err(crate::safety::not_found("cat", "file", path_str));
}
// SECURITY: Validate path to prevent traversal attacks (CVSS 8.2)
let validated_path = validate_read_path(path_str)?;
// SECURITY FIX (MED-001): Check file size before reading
let metadata = fs::metadata(&validated_path)
.map_err(|e| crate::safety::fs_error("cat", &validated_path, &e))?;
check_file_size_limit(metadata.len()).context("cat: file too large")?;
fs::read_to_string(&validated_path)
.map_err(|e| crate::safety::fs_error("cat", &validated_path, &e))
}
fn bi_read_text(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
if args.is_empty() {
return Err(crate::safety::arg_err("read_text: no file specified"));
}
let path_str = match &args[0] {
Value::Str(s) => s,
other => {
return Err(crate::safety::bad_arg(
"read_text",
"path: String",
other.type_name(),
))
}
};
// SECURITY: Validate path to prevent traversal attacks
let validated_path = validate_read_path(path_str)?;
// SECURITY FIX (MED-001): Check file size before reading
let metadata = fs::metadata(&validated_path)
.map_err(|e| crate::safety::fs_error("read_text", &validated_path, &e))?;
check_file_size_limit(metadata.len()).context("read_text: file too large")?;
let content = fs::read_to_string(&validated_path)
.map_err(|e| crate::safety::fs_error("read_text", &validated_path, &e))?;
Ok(Value::Str(content))
}
fn bi_head(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let (count, content) = match input {
Some(Value::Str(content)) => (args.first().map(|v| line_count("head", v)).transpose()?.unwrap_or(10), content),
Some(_) => return Err(crate::safety::arg_err("head piped input must be String")),
None => match args.as_slice() {
[Value::Str(path)] => (10, line_read("head", path)?),
[Value::Str(path), count] => (line_count("head", count)?, line_read("head", path)?),
[count, Value::Str(path)] => (line_count("head", count)?, line_read("head", path)?),
_ => return Err(crate::safety::arg_err("head requires a file path and optional count, or count then path, or a piped String")),
},
};
Ok(Value::Str(
content.lines().take(count).collect::<Vec<_>>().join("\n"),
))
}
fn line_count(builtin: &str, value: &Value) -> Result<usize> {
let Value::Int(n) = value else {
return Err(crate::safety::arg_err(format!(
"{builtin} count must be a nonnegative Int"
)));
};
usize::try_from(*n).map_err(|_| {
crate::safety::arg_err(format!(
"{builtin} count must be a nonnegative representable Int"
))
})
}
/// Read a whole text file a caller named, up to the resource limit
/// (`max_file_size_mb`, 100 MiB by default). A larger file is
/// E_BUDGET_EXCEEDED, not an allocation the size of the file. The limit is
/// enforced on the bytes read, not only on the size the metadata reports, so a
/// file that grows, or a device or pipe that reports 0, is held to it too.
/// Path validation stays with the caller.
fn read_text_capped(builtin: &str, path: impl AsRef<std::path::Path>) -> Result<String> {
read_text_capped_at(
builtin,
path.as_ref(),
crate::security::max_file_size_bytes()?,
)
}
fn read_text_capped_at(builtin: &str, path: &std::path::Path, max: u64) -> Result<String> {
let bytes = read_bytes_capped_at(builtin, path, max)?;
// The error `read_to_string` gave for bytes that are not UTF-8, unchanged.
String::from_utf8(bytes).map_err(|_| {
crate::safety::fs_error(
builtin,
path,
&std::io::Error::new(
std::io::ErrorKind::InvalidData,
"stream did not contain valid UTF-8",
),
)
})
}
/// Read a whole binary file a caller named, up to the resource limit, as
/// `read_text_capped` does for text. Path validation stays with the caller.
fn read_bytes_capped(builtin: &str, path: impl AsRef<std::path::Path>) -> Result<Vec<u8>> {
read_bytes_capped_at(
builtin,
path.as_ref(),
crate::security::max_file_size_bytes()?,
)
}
fn read_bytes_capped_at(builtin: &str, path: &std::path::Path, max: u64) -> Result<Vec<u8>> {
use std::io::Read;
let over = || {
crate::safety::budget_exceeded(
builtin,
format!(
"{builtin}: `{}` is larger than {} MiB",
path.display(),
max / (1024 * 1024)
),
"read a part of the file (head, tail, line_read) or raise max_file_size_mb",
)
};
let file = fs::File::open(path).map_err(|e| crate::safety::fs_error(builtin, path, &e))?;
if file.metadata().is_ok_and(|m| m.len() > max) {
return Err(over());
}
let mut bytes = Vec::new();
file.take(max.saturating_add(1))
.read_to_end(&mut bytes)
.map_err(|e| crate::safety::fs_error(builtin, path, &e))?;
if bytes.len() as u64 > max {
return Err(over());
}
Ok(bytes)
}
/// Whether a capped read failed because the path does not exist. The capped
/// readers report I/O errors as `SafetyError`s, so the `io::ErrorKind` is gone.
fn capped_read_not_found(e: &anyhow::Error) -> bool {
e.downcast_ref::<crate::safety::SafetyError>()
.is_some_and(|s| s.code == crate::safety::ErrorCode::NotFound)
}
fn line_read(builtin: &str, path: &str) -> Result<String> {
use std::io::Read;
const MAX_BYTES: u64 = 64 * 1024 * 1024;
let path = validate_read_path(path)?;
let metadata = fs::metadata(&path).map_err(|e| crate::safety::fs_error(builtin, &path, &e))?;
if metadata.len() > MAX_BYTES {
return Err(crate::safety::budget_exceeded(
builtin,
"file exceeds 64 MiB".into(),
"use a smaller file",
));
}
if !metadata.is_file() {
return Err(crate::safety::arg_err(format!(
"{builtin} requires a regular UTF-8 file"
)));
}
let file = fs::File::open(&path).map_err(|e| crate::safety::fs_error(builtin, &path, &e))?;
let mut content = String::new();
file.take(MAX_BYTES + 1)
.read_to_string(&mut content)
.map_err(|e| crate::safety::fs_error(builtin, &path, &e))?;
if content.len() as u64 > MAX_BYTES {
return Err(crate::safety::budget_exceeded(
builtin,
"file exceeds 64 MiB".into(),
"use a smaller file",
));
}
Ok(content)
}
fn bi_tail(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let (count, content) = if let Some(value) = input {
let Value::Str(content) = value else {
return Err(crate::safety::arg_err("tail piped input must be String"));
};
let count = match args.first() {
None => 10,
Some(value) => line_count("tail", value)?,
};
(count, content)
} else {
match args.as_slice() {
[Value::Str(path)] => (10,line_read("tail", path)?),
[Value::Str(path),count] => (line_count("tail", count)?,line_read("tail", path)?),
[count,Value::Str(path)] => (line_count("tail", count)?,line_read("tail", path)?),
_=>return Err(crate::safety::arg_err("tail requires a file path and optional count, or count then path, or a piped String")),
}
};
let mut lines: Vec<_> = content.lines().rev().take(count).collect();
lines.reverse();
Ok(Value::Str(lines.join("\n")))
}
/// The most entries one directory walk may return. A walk over `/` or `C:\`
/// could otherwise build an array of every file on the disk before anyone saw
/// it. Past this the walk is refused with E_BUDGET_EXCEEDED, not cut short: a
/// shorter listing presented as the whole is the silent wrong answer.
pub(crate) const WALK_MAX_ENTRIES: usize = 1_000_000;
/// The refusal for a walk that would return more than `max` entries.
fn walk_over(builtin: &str, max: usize) -> anyhow::Error {
crate::safety::budget_exceeded(
builtin,
format!("more than {max} entries; the listing would be partial, so nothing was returned"),
"narrow the root, or filter with a pattern or depth",
)
}
/// Refuses when a walk that already holds `len` entries cannot take one more
/// without exceeding `max`.
fn check_walk_room(builtin: &str, len: usize, max: usize) -> Result<()> {
if len >= max {
return Err(walk_over(builtin, max));
}
Ok(())
}
/// Adds `v` to a walk's output, or refuses once the output is at the cap.
fn push_capped(builtin: &str, out: &mut Vec<Value>, v: Value, max: usize) -> Result<()> {
check_walk_room(builtin, out.len(), max)?;
out.push(v);
Ok(())
}
fn bi_find(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let (path_str, pattern) = if args.len() >= 2 {
match (&args[0], &args[1]) {
(Value::Str(p), Value::Str(pat)) => (p.clone(), Some(pat.clone())),
_ => {
return Err(crate::safety::arg_err(
"find: path and pattern must be strings",
))
}
}
} else if args.len() == 1 {
match &args[0] {
Value::Str(p) => (p.clone(), None),
other => {
return Err(crate::safety::bad_arg(
"find",
"path: String",
other.type_name(),
))
}
}
} else {
(".".to_string(), None)
};
// SECURITY: Validate path to prevent traversal attacks (CVSS 8.2)
let validated_path = validate_read_path(&path_str)?;
let mut results = Vec::new();
// A walk error used to be dropped, so an unreadable directory vanished
// from the listing and the rest was returned as the whole answer.
for entry in WalkDir::new(&validated_path) {
let entry = match entry {
Ok(entry) => entry,
Err(e) => {
return Err(match e.io_error() {
Some(io) => crate::safety::fs_error(
"find",
e.path().unwrap_or(validated_path.as_path()),
io,
),
None => crate::safety::bad_state(
"find",
&e.to_string(),
"a tree without symbolic-link loops",
),
});
}
};
let path_str = entry.path().to_string_lossy().to_string();
if let Some(ref pattern) = pattern {
// Simple glob matching: support * for any characters
let filename = entry.file_name().to_string_lossy();
if shell_glob(
&pattern.chars().collect::<Vec<_>>(),
&filename.chars().collect::<Vec<_>>(),
) {
push_capped("find", &mut results, Value::Str(path_str), WALK_MAX_ENTRIES)?;
}
} else {
push_capped("find", &mut results, Value::Str(path_str), WALK_MAX_ENTRIES)?;
}
}
Ok(Value::Array(results))
}
// `find`'s patterns go through `shell_glob`. This used a matcher that knew
// four forms -- `*`, `*.ext`, `prefix*` and an exact name -- and treated
// anything else as an exact name, so `find("a*b.rs")`, `find("?.rs")` and
// `find("[ab].rs")` silently found nothing. `shell_glob` is a strict superset
// of those four, so no pattern that worked before behaves differently.
fn bi_sort(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let reverse = args
.iter()
.any(|arg| matches!(arg, Value::Str(s) if s == "-r" || s == "--reverse"));
let array = if let Some(input) = input {
match input {
Value::Array(arr) => arr,
Value::Str(s) => {
let lines: Vec<Value> =
s.lines().map(|line| Value::Str(line.to_string())).collect();
lines
}
_ => {
return Err(crate::safety::arg_err(
"sort: input must be an array or string",
))
}
}
} else {
return Err(crate::safety::bad_arg(
"sort",
"a piped Array or String",
"a direct call with no subject",
));
};
let mut sorted = array;
sorted.sort_by(|a, b| {
let cmp = match (a, b) {
(Value::Str(s1), Value::Str(s2)) => s1.cmp(s2),
(Value::Int(i1), Value::Int(i2)) => i1.cmp(i2),
(Value::Float(f1), Value::Float(f2)) => {
f1.partial_cmp(f2).unwrap_or(std::cmp::Ordering::Equal)
}
_ => std::cmp::Ordering::Equal,
};
if reverse {
cmp.reverse()
} else {
cmp
}
});
Ok(Value::Array(sorted))
}
fn bi_uniq(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let _count = args
.iter()
.any(|arg| matches!(arg, Value::Str(s) if s == "-c" || s == "--count"));
let array = if let Some(input) = input {
match input {
Value::Array(arr) => arr,
Value::Str(s) => {
let lines: Vec<Value> =
s.lines().map(|line| Value::Str(line.to_string())).collect();
lines
}
_ => {
return Err(crate::safety::arg_err(
"uniq: input must be an array or string",
))
}
}
} else {
return Err(crate::safety::bad_arg(
"uniq",
"a piped Array or String",
"a direct call with no subject",
));
};
let mut unique = Vec::new();
let mut last: Option<&Value> = None;
for item in &array {
if last != Some(item) {
unique.push(item.clone());
last = Some(item);
}
}
Ok(Value::Array(unique))
}
fn bi_wc(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let lines_only = args
.iter()
.any(|arg| matches!(arg, Value::Str(s) if s == "-l" || s == "--lines"));
let words_only = args
.iter()
.any(|arg| matches!(arg, Value::Str(s) if s == "-w" || s == "--words"));
let chars_only = args
.iter()
.any(|arg| matches!(arg, Value::Str(s) if s == "-c" || s == "--chars"));
let content_came_from_pipe = input.is_some();
let content = if let Some(input) = input {
match input {
Value::Str(s) => s,
other => {
return Err(crate::safety::bad_arg(
"wc",
"input: String",
other.type_name(),
))
}
}
} else if !args.is_empty() {
let path = match &args[0] {
Value::Str(s) if !s.starts_with('-') => s,
other => {
return Err(crate::safety::bad_arg(
"wc",
"a piped String, or a file path as the first argument",
other.type_name(),
))
}
};
read_text_capped("wc", path)?
} else {
return Err(crate::safety::bad_arg(
"wc",
"a piped String",
"a direct call with no subject",
));
};
// An unknown flag ("-x", "--bogus") was ignored and every count returned.
let flags = if content_came_from_pipe {
&args[..]
} else {
&args[1..]
};
for f in flags {
if !matches!(f, Value::Str(s) if matches!(s.as_str(), "-l" | "--lines" | "-w" | "--words" | "-c" | "--chars"))
{
return Err(crate::safety::bad_arg(
"wc",
"a flag -l, -w or -c",
&f.to_display_string(),
));
}
}
let line_count = content.lines().count();
let word_count = content.split_whitespace().count();
let char_count = content.chars().count();
let mut result = BTreeMap::new();
if lines_only {
result.insert("lines".to_string(), Value::Int(line_count as i64));
} else if words_only {
result.insert("words".to_string(), Value::Int(word_count as i64));
} else if chars_only {
result.insert("chars".to_string(), Value::Int(char_count as i64));
} else {
result.insert("lines".to_string(), Value::Int(line_count as i64));
result.insert("words".to_string(), Value::Int(word_count as i64));
result.insert("chars".to_string(), Value::Int(char_count as i64));
}
Ok(Value::Record(result))
}
fn bi_grep(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
if args.is_empty() {
return Err(crate::safety::arg_err("grep: pattern required"));
}
let pattern = match &args[0] {
Value::Str(s) => s,
other => {
return Err(crate::safety::bad_arg(
"grep",
"pattern: String",
other.type_name(),
))
}
};
let case_insensitive = args
.iter()
.any(|arg| matches!(arg, Value::Str(s) if s == "-i" || s == "--ignore-case"));
let invert = args
.iter()
.any(|arg| matches!(arg, Value::Str(s) if s == "-v" || s == "--invert-match"));
let content = if let Some(input) = input {
match input {
Value::Str(s) => s,
Value::Array(arr) => {
let lines: Vec<String> = arr
.iter()
.filter_map(|v| {
if let Value::Str(s) = v {
Some(s.clone())
} else {
None
}
})
.collect();
lines.join("\n")
}
_ => {
return Err(crate::safety::arg_err(
"grep: input must be a string or array",
))
}
}
} else if args.len() > 1 {
let path = match &args[1] {
Value::Str(s) if !s.starts_with('-') => s,
_ => {
return Err(crate::safety::bad_arg(
"grep",
"a piped String, or a file path as the second argument",
"nothing",
))
}
};
read_text_capped("grep", path)?
} else {
return Err(crate::safety::bad_arg(
"grep",
"a piped String, or a file path as the second argument",
"a direct call with no subject",
));
};
// The declaration always said "text or regex"; the body only ever did a
// substring search, so `grep("^version", "Cargo.toml")` answered [] --
// an empty result for a pattern every grep user writes. The pattern is
// now what grep takes: a POSIX basic regular expression.
let re = crate::posix::bre_to_regex(pattern)
.and_then(|r| {
regex::RegexBuilder::new(&r)
.case_insensitive(case_insensitive)
.build()
.ok()
})
.ok_or_else(|| {
crate::safety::bad_arg(
"grep",
"a basic regular expression (back-references are not supported)",
pattern,
)
})?;
let matching_lines: Vec<Value> = content
.lines()
.filter(|line| re.is_match(line) != invert)
.map(|line| Value::Str(line.to_string()))
.collect();
Ok(Value::Array(matching_lines))
}
// --------------- Formatting helpers ---------------
fn format_value_one_line(v: &Value) -> String {
// keep this consistent with your pretty module; simplified for echo
match v {
Value::Null => "null".into(),
Value::Bool(b) => b.to_string(),
Value::Int(n) => n.to_string(),
Value::Float(x) => x.to_string(),
Value::Str(s) => s.clone(),
Value::Uri(s) => s.clone(),
Value::Array(a) => format!("[len={}]", a.len()),
Value::Record(_) => "{…}".into(),
Value::Table(t) => format!("<Table rows={}>", t.rows.len()),
Value::Lambda(_) => "<lambda>".into(),
Value::AsyncLambda(_) => "<async lambda>".into(),
Value::Future(_) => "<future>".into(),
Value::Builtin(b) => format!("<builtin:{}>", b.name),
Value::Error(msg) => format!("Error: {}", msg),
}
}
// Helper function to convert Value to string representation
fn value_to_string(value: &Value) -> String {
match value {
Value::Str(s) => s.clone(),
Value::Int(i) => i.to_string(),
Value::Float(f) => f.to_string(),
Value::Bool(b) => b.to_string(),
Value::Uri(s) => s.clone(),
Value::Array(a) => format!("[len={}]", a.len()),
Value::Record(_) => "{…}".into(),
Value::Table(t) => format!("<Table rows={}>", t.rows.len()),
Value::Lambda(_) => "<lambda>".into(),
Value::AsyncLambda(_) => "<async lambda>".into(),
Value::Future(_) => "<future>".into(),
Value::Null => "<null>".into(),
Value::Error(msg) => format!("Error: {}", msg),
Value::Builtin(b) => format!("<builtin:{}>", b.name),
}
}
// =============== PowerShell-style Parameter Binding ===============
/// PowerShell-style parameter binding result
// =============== PowerShell-style Cmdlets ===============
/// Get-Files: PowerShell-style file listing.
///
/// Grammar: at most one path, given positionally or as -Path / -p; -Recurse /
/// -r; and -Filter / -f with a substring of the name. The path comes from the
/// pipe or from the arguments, never both. Anything outside that grammar is
/// refused before the filesystem is read.
fn bi_get_files(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "Get-Files";
let mut path_arg: Option<String> = None;
let mut recurse = false;
let mut filter: Option<String> = None;
let mut i = 0;
while i < args.len() {
let Value::Str(token) = &args[i] else {
return Err(crate::safety::arg_err(format!(
"{NAME}: arguments must be strings, got {}",
args[i].type_name()
)));
};
match token.as_str() {
"-Recurse" | "-r" => {
recurse = true;
i += 1;
}
"-Path" | "-p" | "-Filter" | "-f" => {
let Some(Value::Str(value)) = args.get(i + 1) else {
return Err(crate::safety::arg_err(format!(
"{NAME}: {token} needs a string value"
)));
};
let slot = if matches!(token.as_str(), "-Path" | "-p") {
&mut path_arg
} else {
&mut filter
};
if slot.is_some() {
return Err(crate::safety::arg_err(format!(
"{NAME}: {token} is given more than once"
)));
}
*slot = Some(value.clone());
i += 2;
}
other if other.starts_with('-') => {
return Err(crate::safety::arg_err(format!(
"{NAME}: unknown parameter {other}"
)));
}
_ => {
if path_arg.is_some() {
return Err(crate::safety::arg_err(format!(
"{NAME}: the path is given more than once"
)));
}
path_arg = Some(token.clone());
i += 1;
}
}
}
let path = match (input, path_arg) {
(None, None) => ".".to_string(),
(None, Some(path)) => path,
(Some(Value::Str(piped)), None) => piped,
(Some(other), None) => {
return Err(crate::safety::arg_err(format!(
"{NAME}: pipeline input must be a path string, got {}",
other.type_name()
)))
}
(Some(_), Some(_)) => {
return Err(crate::safety::arg_err(format!(
"{NAME}: the path is both piped and given as an argument; give it once"
)))
}
};
let mut files = Vec::new();
if recurse {
for entry in WalkDir::new(&path).min_depth(1) {
let entry = entry.map_err(|e| walk_failure(&path, e))?;
let metadata = entry.metadata().map_err(|e| walk_failure(&path, e))?;
let name = entry.file_name().to_string_lossy().to_string();
if filter.as_ref().is_some_and(|p| !name.contains(p.as_str())) {
continue;
}
let full = entry.path().to_string_lossy().to_string();
push_capped(
NAME,
&mut files,
file_record(name, full, &metadata),
WALK_MAX_ENTRIES,
)?;
}
} else {
let listing =
fs::read_dir(&path).map_err(|e| crate::safety::fs_error("get_files", &path, &e))?;
for entry in listing {
let entry = entry.map_err(|e| crate::safety::fs_error("get_files", &path, &e))?;
let metadata = entry
.metadata()
.map_err(|e| crate::safety::fs_error("get_files", entry.path(), &e))?;
let name = entry.file_name().to_string_lossy().to_string();
if filter.as_ref().is_some_and(|p| !name.contains(p.as_str())) {
continue;
}
let full = entry.path().to_string_lossy().to_string();
push_capped(
NAME,
&mut files,
file_record(name, full, &metadata),
WALK_MAX_ENTRIES,
)?;
}
}
Ok(Value::Array(files))
}
fn file_record(name: String, full_name: String, metadata: &fs::Metadata) -> Value {
let mut file_obj = BTreeMap::new();
file_obj.insert("Name".to_string(), Value::Str(name));
file_obj.insert("FullName".to_string(), Value::Str(full_name));
file_obj.insert("IsDirectory".to_string(), Value::Bool(metadata.is_dir()));
file_obj.insert("Length".to_string(), Value::Int(metadata.len() as i64));
if let Ok(modified) = metadata.modified() {
if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
file_obj.insert(
"LastWriteTime".to_string(),
Value::Int(duration.as_secs() as i64),
);
}
}
Value::Record(file_obj)
}
/// A walk that cannot read an entry is a failure, not a shorter listing.
fn walk_failure(path: &str, e: walkdir::Error) -> anyhow::Error {
match e.io_error() {
Some(io) => crate::safety::fs_error("get_files", path, io),
None => crate::safety::bad_state(
"Get-Files",
&format!("{path}: {e}"),
"a tree without symbolic-link loops",
),
}
}
/// Get-Content: PowerShell-style file content reading, one element per line.
///
/// The path comes from the pipe or from a single argument, never both.
fn bi_get_content(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "Get-Content";
if args.len() > 1 {
return Err(crate::safety::arg_err(format!(
"{NAME}: takes one path, got {} arguments",
args.len()
)));
}
let path = match (input, args.into_iter().next()) {
(None, None) => return Err(crate::safety::arg_err(format!("{NAME}: no path provided"))),
(None, Some(Value::Str(path))) | (Some(Value::Str(path)), None) => path,
(None, Some(other)) => {
return Err(crate::safety::arg_err(format!(
"{NAME}: path must be a string, got {}",
other.type_name()
)))
}
(Some(other), None) => {
return Err(crate::safety::arg_err(format!(
"{NAME}: pipeline input must be a path string, got {}",
other.type_name()
)))
}
(Some(_), Some(_)) => {
return Err(crate::safety::arg_err(format!(
"{NAME}: the path is both piped and given as an argument; give it once"
)))
}
};
let content = read_text_capped("get_content", &path)?;
// Return as array of lines (PowerShell style)
let lines: Vec<Value> = content
.lines()
.map(|line| Value::Str(line.to_string()))
.collect();
Ok(Value::Array(lines))
}
/// Select-Object: PowerShell-style property selection.
///
/// Each argument names a property to keep on every piped record. A piped item
/// that is not a record is refused, not passed through unchanged.
fn bi_select_object(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "Select-Object";
let input =
input.ok_or_else(|| crate::safety::arg_err("Select-Object: requires pipeline input"))?;
if args.is_empty() {
return Ok(input);
}
if let Some(bad) = args.iter().find(|a| !matches!(a, Value::Str(_))) {
return Err(crate::safety::arg_err(format!(
"{NAME}: property names must be strings, got {}",
bad.type_name()
)));
}
match input {
Value::Array(arr) => {
let mut results = Vec::with_capacity(arr.len());
for item in &arr {
results.push(select_properties(item, &args)?);
}
Ok(Value::Array(results))
}
other => select_properties(&other, &args),
}
}
fn select_properties(value: &Value, properties: &[Value]) -> Result<Value> {
let Value::Record(record) = value else {
return Err(crate::safety::bad_arg(
"Select-Object",
"a record to select properties from",
value.type_name(),
));
};
let mut selected = BTreeMap::new();
for prop in properties {
if let Value::Str(prop_name) = prop {
if let Some(val) = record.get(prop_name) {
selected.insert(prop_name.clone(), val.clone());
}
}
}
Ok(Value::Record(selected))
}
/// Where-Object: PowerShell-style filtering.
///
/// Two forms: `where-object(fn(element) => Bool)`, or
/// `where-object(property, operator, value)`. Both need a piped array.
/// The cmdlet name in `Where-Object` messages, kept out of the format strings
/// because tests/no_raw_shell_interpolation.rs reads a cmdlet name inside a
/// `format!` as a PowerShell command being built.
const WHERE_OBJECT: &str = "Where-Object";
fn bi_where_object(args: Vec<Value>, input: Option<Value>, env: &mut Env) -> Result<Value> {
const NAME: &str = "Where-Object";
let input =
input.ok_or_else(|| crate::safety::arg_err("Where-Object: requires pipeline input"))?;
match args.first() {
None => Err(crate::safety::arg_err(
"Where-Object: requires filter condition",
)),
// Lambda filtering: the existing where logic.
Some(Value::Lambda(_)) => {
if args.len() != 1 {
return Err(crate::safety::arg_err(format!(
"{NAME}: a lambda filter takes one argument, got {}",
args.len()
)));
}
bi_where(args.clone(), Some(input), env)
}
// PowerShell-style property filtering: where Status -eq "Running"
Some(Value::Str(property)) => {
if args.len() != 3 {
return Err(crate::safety::arg_err(format!(
"{NAME}: property filtering takes a property, an operator and a value, got {} arguments",
args.len()
)));
}
filter_by_property(&input, property, &args[1], &args[2])
}
Some(other) => Err(crate::safety::arg_err(format!(
"{NAME}: the filter must be a lambda or a property name, got {}",
other.type_name()
))),
}
}
fn filter_by_property(
input: &Value,
property: &str,
operator: &Value,
target: &Value,
) -> Result<Value> {
let op_str = match operator {
Value::Str(s) => s.as_str(),
other => {
return Err(crate::safety::arg_err(format!(
"{WHERE_OBJECT}: operator must be a string, got {}",
other.type_name()
)))
}
};
// Checked before any element is read: a bad operator is refused even when
// the array is empty or no element carries the property.
match op_str {
"-eq" | "==" | "-ne" | "!=" => {}
"-like" => {
if !matches!(target, Value::Str(_)) {
return Err(crate::safety::arg_err(format!(
"{WHERE_OBJECT}: -like needs a string value, got {}",
target.type_name()
)));
}
}
"-gt" | ">" | "-lt" | "<" | "-ge" | ">=" | "-le" | "<=" => {
if !matches!(target, Value::Int(_) | Value::Float(_) | Value::Str(_)) {
return Err(crate::safety::arg_err(format!(
"{WHERE_OBJECT}: {op_str} needs a number or a string value, got {}",
target.type_name()
)));
}
}
other => {
return Err(crate::safety::bad_arg(
"Where-Object",
"an operator: -eq -ne -gt -lt -ge -le -like",
other,
))
}
}
let Value::Array(items) = input else {
return Err(crate::safety::bad_arg(
"Where-Object",
"an array as the piped subject",
input.type_name(),
));
};
let mut results = Vec::new();
for item in items {
let Value::Record(record) = item else {
continue;
};
let Some(value) = record.get(property) else {
continue;
};
let matches = match op_str {
"-eq" | "==" => value == target,
"-ne" | "!=" => value != target,
"-gt" | ">" => compare_values(value, target, std::cmp::Ordering::Greater),
"-lt" | "<" => compare_values(value, target, std::cmp::Ordering::Less),
"-ge" | ">=" => {
compare_values(value, target, std::cmp::Ordering::Greater) || value == target
}
"-le" | "<=" => {
compare_values(value, target, std::cmp::Ordering::Less) || value == target
}
// "-like" is the only operator left, and its target is a string.
_ => {
matches!((value, target), (Value::Str(v), Value::Str(t)) if v.contains(t.as_str()))
}
};
if matches {
results.push(item.clone());
}
}
Ok(Value::Array(results))
}
fn compare_values(a: &Value, b: &Value, expected: std::cmp::Ordering) -> bool {
match (a, b) {
(Value::Int(x), Value::Int(y)) => x.cmp(y) == expected,
(Value::Float(x), Value::Float(y)) => x.partial_cmp(y) == Some(expected),
(Value::Int(x), Value::Float(y)) => (*x as f64).partial_cmp(y) == Some(expected),
(Value::Float(x), Value::Int(y)) => x.partial_cmp(&(*y as f64)) == Some(expected),
(Value::Str(x), Value::Str(y)) => x.cmp(y) == expected,
_ => false,
}
}
/// ForEach-Object: PowerShell-style iteration
fn bi_foreach_object(args: Vec<Value>, input: Option<Value>, env: &mut Env) -> Result<Value> {
// This is essentially the same as map, but with PowerShell naming
bi_map(args, input, env)
}
/// Sort-Object: PowerShell-style sorting, by value or by one property.
fn bi_sort_object(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "Sort-Object";
let input =
input.ok_or_else(|| crate::safety::arg_err("Sort-Object: requires pipeline input"))?;
if args.len() > 1 {
return Err(crate::safety::arg_err(format!(
"{NAME}: sorts by one property, got {} arguments",
args.len()
)));
}
let property = match args.first() {
None => None,
Some(Value::Str(property)) => Some(property.as_str()),
Some(other) => {
return Err(crate::safety::arg_err(format!(
"{NAME}: property name must be a string, got {}",
other.type_name()
)))
}
};
let mut arr = match input {
Value::Array(arr) => arr,
other => {
return Err(crate::safety::bad_arg(
NAME,
"an array as the piped subject",
other.type_name(),
))
}
};
match property {
None => arr.sort_by(compare_values_for_sort),
Some(property) => {
if let Some(other) = arr.iter().find(|v| !matches!(v, Value::Record(_))) {
return Err(crate::safety::bad_arg(
NAME,
"records when sorting by a property",
other.type_name(),
));
}
arr.sort_by(|a, b| {
compare_values_for_sort(
&get_property_value(a, property),
&get_property_value(b, property),
)
});
}
}
Ok(Value::Array(arr))
}
fn get_property_value(value: &Value, property: &str) -> Value {
match value {
Value::Record(record) => record
.get(property)
.cloned()
.unwrap_or(Value::Str("".to_string())),
_ => value.clone(),
}
}
fn compare_values_for_sort(a: &Value, b: &Value) -> std::cmp::Ordering {
match (a, b) {
(Value::Int(x), Value::Int(y)) => x.cmp(y),
(Value::Float(x), Value::Float(y)) => x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal),
(Value::Int(x), Value::Float(y)) => (*x as f64)
.partial_cmp(y)
.unwrap_or(std::cmp::Ordering::Equal),
(Value::Float(x), Value::Int(y)) => x
.partial_cmp(&(*y as f64))
.unwrap_or(std::cmp::Ordering::Equal),
(Value::Str(x), Value::Str(y)) => x.cmp(y),
(Value::Bool(x), Value::Bool(y)) => x.cmp(y),
_ => std::cmp::Ordering::Equal,
}
}
/// Group-Object: PowerShell-style grouping
fn bi_group_object(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let input =
input.ok_or_else(|| crate::safety::arg_err("Group-Object: requires pipeline input"))?;
if args.is_empty() {
return Err(crate::safety::arg_err(
"Group-Object: requires property name",
));
}
let property = match &args[0] {
Value::Str(s) => s,
_ => {
return Err(crate::safety::arg_err(
"Group-Object: property name must be a string",
))
}
};
match input {
Value::Array(arr) => {
let mut groups = BTreeMap::new();
for item in arr {
let key = get_property_value(&item, property);
let key_str = value_to_string(&key);
groups.entry(key_str).or_insert_with(Vec::new).push(item);
}
let mut result = Vec::new();
for (key, items) in groups {
let mut group = BTreeMap::new();
group.insert("Name".to_string(), Value::Str(key));
group.insert("Count".to_string(), Value::Int(items.len() as i64));
group.insert("Group".to_string(), Value::Array(items));
result.push(Value::Record(group));
}
Ok(Value::Array(result))
}
_ => Err(crate::safety::arg_err(
"Group-Object: input must be an array",
)),
}
}
/// Measure-Object: PowerShell-style count and, for one property, its statistics.
///
/// A piped array is measured element by element; any other piped value counts
/// as a single element. Statistics come from the numeric values of the property.
fn bi_measure_object(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "Measure-Object";
let input =
input.ok_or_else(|| crate::safety::arg_err("Measure-Object: requires pipeline input"))?;
if args.len() > 1 {
return Err(crate::safety::arg_err(format!(
"{NAME}: takes one property name, got {} arguments",
args.len()
)));
}
let property = match args.first() {
None => None,
Some(Value::Str(property)) => Some(property.as_str()),
Some(other) => {
return Err(crate::safety::arg_err(format!(
"{NAME}: property name must be a string, got {}",
other.type_name()
)))
}
};
let items = match input {
Value::Array(arr) => arr,
other => vec![other],
};
let mut result = BTreeMap::new();
result.insert("Count".to_string(), Value::Int(items.len() as i64));
if let Some(property) = property {
let values: Vec<f64> = items
.iter()
.filter_map(|item| match item {
Value::Record(record) => match record.get(property) {
Some(Value::Int(i)) => Some(*i as f64),
Some(Value::Float(f)) => Some(*f),
_ => None,
},
_ => None,
})
.collect();
if !values.is_empty() {
let sum: f64 = values.iter().sum();
let avg = sum / values.len() as f64;
let min = values.iter().fold(f64::INFINITY, |a, &b| a.min(b));
let max = values.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
result.insert("Sum".to_string(), Value::Float(sum));
result.insert("Average".to_string(), Value::Float(avg));
result.insert("Minimum".to_string(), Value::Float(min));
result.insert("Maximum".to_string(), Value::Float(max));
}
}
Ok(Value::Record(result))
}
// =============== Nushell-style Data Commands ===============
/// from-json: Parse JSON into structured data
fn bi_from_json(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let json_str = if let Some(input) = input {
match input {
Value::Str(s) => s,
_ => {
return Err(crate::safety::arg_err(
"from-json: input must be a JSON string",
))
}
}
} else if !args.is_empty() {
match &args[0] {
Value::Str(s) => s.clone(),
_ => {
return Err(crate::safety::arg_err(
"from-json: argument must be a JSON string",
))
}
}
} else {
return Err(crate::safety::arg_err("from-json: no JSON input provided"));
};
// A bare `?` here surfaced serde's message as E_UNKNOWN with exit 1, the
// one uncoded failure left in benches/agentic/errors.mjs (malformed-json).
let parsed: serde_json::Value = serde_json::from_str(&json_str).map_err(|e| {
// `from_json("issues.json")` -- the parser handed a path -- was every
// IronShell attempt a small model made in the local E7 run. It stays
// an error (the text is not JSON), but the repair is mechanical, so
// the hint names it.
let path = json_str.trim();
// is_file() is a stat, and on Windows a stat of `\\host\share` opens an
// SMB session, so a remote-share string is never probed (as in lsof).
if !path.is_empty()
&& path.len() < 4096
&& !crate::safety::names_a_remote_share(path)
&& std::path::Path::new(path).is_file()
{
return anyhow::Error::new(crate::safety::SafetyError {
code: crate::safety::ErrorCode::BadArg,
message: format!("from_json: {path:?} is a file name, not JSON text"),
builtin: "from_json".to_string(),
hint: format!(
"read and parse the file with open({path:?}), or cat({path:?}) | from_json"
),
approval: None,
did_you_mean: vec![format!("open({path:?})")],
expected: "JSON text".to_string(),
got: "a file path".to_string(),
});
}
crate::safety::bad_arg("from_json", "valid JSON", &e.to_string())
})?;
Ok(json_to_value(parsed))
}
/// to-json: Convert structured data to JSON
fn bi_to_json(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let value = input.ok_or_else(|| crate::safety::arg_err("to-json: requires pipeline input"))?;
let pretty = args
.first()
.and_then(|arg| match arg {
Value::Bool(b) => Some(*b),
Value::Str(s) => Some(s == "pretty" || s == "true"),
_ => None,
})
.unwrap_or(false);
let json_val = value_to_json(value);
let json_str = if pretty {
serde_json::to_string_pretty(&json_val)?
} else {
serde_json::to_string(&json_val)?
};
Ok(Value::Str(json_str))
}
/// Format bytes into a human-readable string (e.g. "1.5G", "100M", "4.0K")
fn format_bytes(bytes: i64) -> String {
let abs = bytes.unsigned_abs();
if abs >= 1_073_741_824 {
format!("{:.1}G", bytes as f64 / 1_073_741_824.0)
} else if abs >= 1_048_576 {
format!("{:.1}M", bytes as f64 / 1_048_576.0)
} else if abs >= 1024 {
format!("{:.1}K", bytes as f64 / 1024.0)
} else {
format!("{}B", bytes)
}
}
fn json_to_value(json: serde_json::Value) -> Value {
match json {
// Was `Value::Str("null")`: every JSON null reached the value space as
// a four-letter string. It printed identically, so nothing looked
// wrong, but `typeof(from_json("null"))` said String, `x == null` never
// matched a null field, and AECON tagged the column as ambiguous.
serde_json::Value::Null => Value::Null,
serde_json::Value::Bool(b) => Value::Bool(b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Value::Int(i)
} else if let Some(f) = n.as_f64() {
Value::Float(f)
} else {
Value::Str(n.to_string())
}
}
serde_json::Value::String(s) => Value::Str(s),
serde_json::Value::Array(arr) => Value::Array(arr.into_iter().map(json_to_value).collect()),
serde_json::Value::Object(obj) => {
let mut map = BTreeMap::new();
for (k, v) in obj {
map.insert(k, json_to_value(v));
}
Value::Record(map)
}
}
}
fn value_to_json(value: Value) -> serde_json::Value {
match value {
Value::Str(s) => serde_json::Value::String(s),
Value::Int(i) => serde_json::Value::Number(serde_json::Number::from(i)),
Value::Float(f) => serde_json::Value::Number(
serde_json::Number::from_f64(f).unwrap_or_else(|| serde_json::Number::from(0)),
),
Value::Bool(b) => serde_json::Value::Bool(b),
Value::Array(arr) => serde_json::Value::Array(arr.into_iter().map(value_to_json).collect()),
Value::Record(map) => {
let mut obj = serde_json::Map::new();
for (k, v) in map {
obj.insert(k, value_to_json(v));
}
serde_json::Value::Object(obj)
}
_ => serde_json::Value::String(value_to_string(&value)),
}
}
/// from-csv: Parse CSV into structured data
fn bi_from_csv(_args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let csv_str = if let Some(input) = input {
match input {
Value::Str(s) => s,
_ => {
return Err(crate::safety::arg_err(
"from-csv: input must be a CSV string",
))
}
}
} else {
return Err(crate::safety::arg_err("from-csv: requires CSV input"));
};
let mut reader = csv::Reader::from_reader(csv_str.as_bytes());
let headers: Vec<String> = reader
.headers()
.map_err(|e| crate::safety::bad_arg("from-csv", "CSV text", &e.to_string()))?
.iter()
.map(|h| h.to_string())
.collect();
let mut results = Vec::new();
for record in reader.records() {
// A malformed row (unequal field counts, bad quoting) is the caller's
// text, so E_BAD_ARG; it was an uncoded csv::Error, E_UNKNOWN.
let record =
record.map_err(|e| crate::safety::bad_arg("from-csv", "CSV text", &e.to_string()))?;
let mut row = BTreeMap::new();
for (i, field) in record.iter().enumerate() {
if let Some(header) = headers.get(i) {
// Try to parse as number, otherwise keep as string
let value = if let Ok(int_val) = field.parse::<i64>() {
Value::Int(int_val)
} else if let Ok(float_val) = field.parse::<f64>() {
Value::Float(float_val)
} else if let Ok(bool_val) = field.parse::<bool>() {
Value::Bool(bool_val)
} else {
Value::Str(field.to_string())
};
row.insert(header.clone(), value);
}
}
results.push(Value::Record(row));
}
Ok(Value::Array(results))
}
/// to-csv: Convert structured data to CSV
fn bi_to_csv(_args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let input = input.ok_or_else(|| crate::safety::arg_err("to-csv: requires pipeline input"))?;
match input {
Value::Array(arr) => {
if arr.is_empty() {
return Ok(Value::Str(String::new()));
}
// Get headers from first record
let headers = if let Value::Record(first_record) = &arr[0] {
first_record.keys().cloned().collect::<Vec<_>>()
} else {
return Err(crate::safety::arg_err(
"to-csv: input must be array of records",
));
};
let mut csv_output = String::new();
// Write headers
csv_output.push_str(&headers.join(","));
csv_output.push('\n');
// Write data rows
for item in arr {
if let Value::Record(record) = item {
let row: Vec<String> = headers
.iter()
.map(|header| {
record
.get(header)
.map(|v| csv_escape(&value_to_string(v)))
.unwrap_or_default()
})
.collect();
csv_output.push_str(&row.join(","));
csv_output.push('\n');
}
}
Ok(Value::Str(csv_output))
}
_ => Err(crate::safety::arg_err(
"to-csv: input must be an array of records",
)),
}
}
fn csv_escape(s: &str) -> String {
if s.contains(',') || s.contains('"') || s.contains('\n') {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
/// from-yaml: Parse YAML into structured data.
///
/// Delegates to [`crate::yaml`], which implements a documented subset (nested
/// mappings, sequences, quoting, comments, JSON-style flow collections) and
/// returns an error naming any construct outside it. It never silently
/// mis-parses — see that module for why that matters here.
fn bi_from_yaml(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
// Accepts either a pipeline value or a positional argument, matching
// `from-json`'s contract.
let yaml_str = match input {
Some(Value::Str(s)) => s,
Some(_) => {
return Err(crate::safety::arg_err(
"from-yaml: input must be a YAML string",
))
}
None => match args.first() {
Some(Value::Str(s)) => s.clone(),
Some(_) => {
return Err(crate::safety::arg_err(
"from-yaml: argument must be a YAML string",
))
}
None => return Err(crate::safety::arg_err("from-yaml: requires YAML input")),
},
};
crate::yaml::parse(&yaml_str).map_err(|e| crate::safety::arg_err(format!("from-yaml: {e}")))
}
/// to-yaml: Convert structured data to YAML.
///
/// Output is quoted wherever quoting is required, so the result parses back to
/// the same value.
fn bi_to_yaml(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let value = match input {
Some(v) => v,
None => args
.into_iter()
.next()
.ok_or_else(|| crate::safety::arg_err("to-yaml: requires pipeline input"))?,
};
Ok(Value::Str(crate::yaml::emit(&value)))
}
/// columns: Get column names from structured data
fn bi_columns(_args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let input = input.ok_or_else(|| crate::safety::arg_err("columns: requires pipeline input"))?;
match input {
Value::Array(arr) => {
if let Some(Value::Record(first)) = arr.first() {
let columns: Vec<Value> = first.keys().map(|k| Value::Str(k.clone())).collect();
Ok(Value::Array(columns))
} else {
Ok(Value::Array(Vec::new()))
}
}
Value::Record(record) => {
let columns: Vec<Value> = record.keys().map(|k| Value::Str(k.clone())).collect();
Ok(Value::Array(columns))
}
_ => Ok(Value::Array(Vec::new())),
}
}
/// describe: Get type and structure information about data
fn bi_describe(_args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let input = input.ok_or_else(|| crate::safety::arg_err("describe: requires pipeline input"))?;
let mut description = BTreeMap::new();
match &input {
Value::Str(s) => {
description.insert("type".to_string(), Value::Str("string".to_string()));
description.insert("length".to_string(), Value::Int(s.len() as i64));
}
Value::Int(_) => {
description.insert("type".to_string(), Value::Str("integer".to_string()));
}
Value::Float(_) => {
description.insert("type".to_string(), Value::Str("float".to_string()));
}
Value::Bool(_) => {
description.insert("type".to_string(), Value::Str("boolean".to_string()));
}
Value::Array(arr) => {
description.insert("type".to_string(), Value::Str("array".to_string()));
description.insert("length".to_string(), Value::Int(arr.len() as i64));
if !arr.is_empty() {
let first_type = match &arr[0] {
Value::Str(_) => "string",
Value::Int(_) => "integer",
Value::Float(_) => "float",
Value::Bool(_) => "boolean",
Value::Array(_) => "array",
Value::Record(_) => "record",
_ => "unknown",
};
description.insert(
"element_type".to_string(),
Value::Str(first_type.to_string()),
);
}
}
Value::Record(record) => {
description.insert("type".to_string(), Value::Str("record".to_string()));
description.insert("fields".to_string(), Value::Int(record.len() as i64));
let field_names: Vec<Value> = record.keys().map(|k| Value::Str(k.clone())).collect();
description.insert("field_names".to_string(), Value::Array(field_names));
}
_ => {
description.insert("type".to_string(), Value::Str("unknown".to_string()));
}
}
Ok(Value::Record(description))
}
// =============== AI-Enhanced Commands ===============
/// ai: Direct AI query with optional multi-modal support
/// Usage:
/// ai("prompt") - Simple text query
/// ai("prompt", {images: ["path1", "path2"]}) - With images
/// ai("prompt", {audio: ["path"]}) - With audio
/// ai("prompt", {video: ["path"]}) - With video
/// (no model argument: the provider comes from IRONSHELL_AI;
/// a model field is accepted and not used)
fn bi_ai(args: Vec<Value>, input: Option<Value>, _env: &mut Env) -> Result<Value> {
// SECURITY: Rate limit AI calls
check_rate_limit("bi_ai", 30, Duration::from_secs(60)).context("AI rate limit exceeded")?;
// Get prompt from input or first argument
let prompt = if let Some(input) = input {
match input {
Value::Str(s) => s,
_ => return Err(crate::safety::arg_err("ai: input must be a prompt string")),
}
} else if !args.is_empty() {
match &args[0] {
Value::Str(s) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"ai",
"prompt: String",
other.type_name(),
))
}
}
} else {
return Err(crate::safety::arg_err("ai: requires a prompt string"));
};
// SECURITY: Validate AI prompt for injection
let validated_prompt = validate_ai_prompt(&prompt).context("ai: prompt validation failed")?;
// Check for optional config record (second argument)
let config = args.get(1).and_then(|v| {
if let Value::Record(r) = v {
Some(r.clone())
} else {
None
}
});
// Extract multi-modal content if present
let images = config
.as_ref()
.and_then(|c| extract_string_array(c, "images"));
let audio = config
.as_ref()
.and_then(|c| extract_string_array(c, "audio"));
let video = config
.as_ref()
.and_then(|c| extract_string_array(c, "video"));
let _model = config.as_ref().and_then(|c| {
c.get("model").and_then(|v| {
if let Value::Str(s) = v {
Some(s.clone())
} else {
None
}
})
});
// Determine if this is a multi-modal request
let has_media = images.is_some() || audio.is_some() || video.is_some();
if has_media {
// Multi-modal request
let mut content_parts = vec![crate::ai::MultiModalContent {
text: Some(validated_prompt),
image_url: None,
audio_url: None,
video_url: None,
image_data: None,
audio_data: None,
video_data: None,
}];
// Load and encode images
if let Some(img_paths) = images {
for path in img_paths {
// SECURITY: Validate path
let validated_path = validate_read_path(&path)
.context(format!("ai: invalid image path: {}", path))?;
// Read and base64 encode the image
let data = read_bytes_capped("ai", &validated_path).context(format!(
"ai: failed to read image: {}",
validated_path.display()
))?;
use base64::{engine::general_purpose::STANDARD, Engine as _};
let encoded = STANDARD.encode(&data);
content_parts.push(crate::ai::MultiModalContent {
text: None,
image_url: None,
audio_url: None,
video_url: None,
image_data: Some(encoded),
audio_data: None,
video_data: None,
});
}
}
// Load and encode audio
if let Some(audio_paths) = audio {
for path in audio_paths {
let validated_path = validate_read_path(&path)
.context(format!("ai: invalid audio path: {}", path))?;
let data = read_bytes_capped("ai", &validated_path).context(format!(
"ai: failed to read audio: {}",
validated_path.display()
))?;
use base64::{engine::general_purpose::STANDARD, Engine as _};
let encoded = STANDARD.encode(&data);
content_parts.push(crate::ai::MultiModalContent {
text: None,
image_url: None,
audio_url: None,
video_url: None,
image_data: None,
audio_data: Some(encoded),
video_data: None,
});
}
}
// Load and encode video
if let Some(video_paths) = video {
for path in video_paths {
let validated_path = validate_read_path(&path)
.context(format!("ai: invalid video path: {}", path))?;
let data = read_bytes_capped("ai", &validated_path).context(format!(
"ai: failed to read video: {}",
validated_path.display()
))?;
use base64::{engine::general_purpose::STANDARD, Engine as _};
let encoded = STANDARD.encode(&data);
content_parts.push(crate::ai::MultiModalContent {
text: None,
image_url: None,
audio_url: None,
video_url: None,
image_data: None,
audio_data: None,
video_data: Some(encoded),
});
}
}
let message = crate::ai::MultiModalMessage {
role: "user".to_string(),
content: content_parts,
};
let response = crate::ai::complete_multimodal_sync(&[message])?;
Ok(Value::Str(response))
} else {
// Simple text-only request
let response = crate::ai::complete_sync_router(&validated_prompt)?;
Ok(Value::Str(response))
}
}
/// Helper to extract a string array from a record field
fn extract_string_array(record: &BTreeMap<String, Value>, key: &str) -> Option<Vec<String>> {
record.get(key).and_then(|v| {
if let Value::Array(arr) = v {
let strings: Vec<String> = arr
.iter()
.filter_map(|item| {
if let Value::Str(s) = item {
Some(s.clone())
} else {
None
}
})
.collect();
if strings.is_empty() {
None
} else {
Some(strings)
}
} else {
None
}
})
}
// suggest / explain / complete / fix
//
// These were documented as "AI-powered". No model was consulted: each matched
// keywords against canned paragraphs, and the paragraphs told the caller to run
// `Get-Files`, `Get-Content`, `Where-Object`, `Sort-Object`, `from-json`,
// `describe` and `ai-suggest "..."` -- none of which this shell can call (the
// last parses as a subtraction). An agent following the advice failed again.
//
// They now answer from the shell's own catalogue: the dispatcher's names and
// the declared signatures, which the test suite executes. Nothing here claims
// intelligence it does not have.
/// The single string subject of these builtins: piped input or the first
/// argument.
fn hint_subject(builtin: &str, args: &[Value], input: Option<Value>) -> Result<String> {
match input.or_else(|| args.first().cloned()) {
Some(Value::Str(s)) if !s.trim().is_empty() => Ok(s),
other => Err(crate::safety::bad_arg(
builtin,
"a non-empty String (argument or piped input)",
other.as_ref().map(|v| v.type_name()).unwrap_or("nothing"),
)),
}
}
fn hint_signature_record(sig: &crate::signature::Signature) -> Value {
let mut r = BTreeMap::new();
r.insert("name".to_string(), Value::Str(sig.name.to_string()));
r.insert("signature".to_string(), Value::Str(sig.render()));
r.insert("doc".to_string(), Value::Str(sig.doc.to_string()));
Value::Record(r)
}
/// suggest(query): declared builtins whose name or description contains every
/// word of the query, best (most words in the name) first; at most 10.
fn bi_ai_suggest(args: Vec<Value>, input: Option<Value>, _env: &mut Env) -> Result<Value> {
let query = hint_subject("suggest", &args, input)?.to_lowercase();
let words: Vec<&str> = query.split_whitespace().collect();
let mut hits: Vec<(usize, &crate::signature::Signature)> = crate::signature::SIGNATURES
.iter()
.filter_map(|sig| {
let name = sig.name.to_lowercase();
let text = format!("{} {}", name, sig.doc.to_lowercase());
words
.iter()
.all(|w| text.contains(w))
.then(|| (words.iter().filter(|w| name.contains(*w)).count(), sig))
})
.collect();
hits.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.name.cmp(b.1.name)));
Ok(Value::Array(
hits.into_iter()
.take(10)
.map(|(_, sig)| hint_signature_record(sig))
.collect(),
))
}
/// explain(subject): a builtin's declared signature and description, or what
/// an error code in the subject means.
fn bi_ai_explain(args: Vec<Value>, input: Option<Value>, _env: &mut Env) -> Result<Value> {
let subject = hint_subject("explain", &args, input)?;
if let Some(sig) = crate::signature::signature_of(subject.trim()) {
return Ok(hint_signature_record(sig));
}
if is_dispatched(subject.trim()) {
let mut r = BTreeMap::new();
r.insert("name".to_string(), Value::Str(subject.trim().to_string()));
r.insert(
"doc".to_string(),
Value::Str(
"a builtin with no declared signature yet; its arguments are not described"
.to_string(),
),
);
return Ok(Value::Record(r));
}
if subject.contains("E_") {
return bi_diagnose(vec![hint_error_value(&subject)], None);
}
Err(crate::safety::not_found(
"explain",
"builtin or error code",
subject.trim(),
))
}
/// complete(partial): builtin names that start with `partial`, sorted,
/// at most 20.
fn bi_ai_complete(args: Vec<Value>, input: Option<Value>, _env: &mut Env) -> Result<Value> {
let partial = hint_subject("complete", &args, input)?;
let mut names: Vec<&str> = BUILTIN_LOOKUP
.keys()
.copied()
.chain(FALLBACK_BUILTINS.iter().map(|(n, _)| *n))
.filter(|n| n.starts_with(partial.trim()))
.collect();
names.sort_unstable();
names.dedup();
Ok(Value::Array(
names
.into_iter()
.take(20)
.map(|n| Value::Str(n.to_string()))
.collect(),
))
}
/// An error given as text: the JSON a coded error prints, or plain words.
fn hint_error_value(text: &str) -> Value {
let t = text.trim();
let json = t
.find('{')
.and_then(|i| serde_json::from_str::<serde_json::Value>(&t[i..]).ok());
match json.map(json_to_value) {
Some(Value::Record(r)) => match r.get("error") {
Some(inner @ Value::Record(_)) => inner.clone(),
_ => Value::Record(r),
},
_ => match t.find("E_") {
// "error[E_BAD_ARG]: ..." as the CLI prints it
Some(i) => {
let code: String = t[i..]
.chars()
.take_while(|c| c.is_ascii_uppercase() || *c == '_')
.collect();
let mut r = BTreeMap::new();
r.insert("code".to_string(), Value::Str(code));
r.insert("message".to_string(), Value::Str(t.to_string()));
Value::Record(r)
}
None => Value::Str(t.to_string()),
},
}
}
/// fix(error): the minimal repair context for an error, as diagnose gives
/// for a caught error record, from the error's text.
fn bi_ai_fix(args: Vec<Value>, input: Option<Value>, _env: &mut Env) -> Result<Value> {
let error = match input.or_else(|| args.first().cloned()) {
Some(v @ Value::Record(_)) => v,
Some(Value::Str(s)) if !s.trim().is_empty() => hint_error_value(&s),
other => {
return Err(crate::safety::bad_arg(
"fix",
"an error: the record a catch bound, or its text",
other.as_ref().map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
bi_diagnose(vec![error], None)
}
// ============ Option Type Constructors ============
/// Some(value) - Creates an Option variant containing a value
/// Returns: Record with _tag="Some" and _value=<arg>
fn bi_some(args: Vec<Value>) -> Result<Value> {
if args.len() != 1 {
return Err(crate::safety::arg_err(format!(
"Some expects exactly 1 argument, got {}",
args.len()
)));
}
let mut record = BTreeMap::new();
record.insert("_tag".to_string(), Value::Str("Some".to_string()));
record.insert("_value".to_string(), args[0].clone());
Ok(Value::Record(record))
}
/// None - Creates an Option variant representing no value
/// Returns: Record with _tag="None"
fn bi_none() -> Result<Value> {
let mut record = BTreeMap::new();
record.insert("_tag".to_string(), Value::Str("None".to_string()));
Ok(Value::Record(record))
}
/// The one string argument a builtin takes; anything else is refused by name.
fn sole_str(builtin: &str, args: &[Value], expected: &str) -> Result<String> {
match args {
[Value::Str(s)] => Ok(s.clone()),
[other] => Err(crate::safety::bad_arg(builtin, expected, other.type_name())),
_ => Err(crate::safety::bad_arg(
builtin,
expected,
&format!("{} argument(s)", args.len()),
)),
}
}
/// The single config record a builtin takes. A table, a string or any other
/// value is refused, because a config that is silently ignored is not a config.
fn cfg_record<'a>(
builtin: &str,
args: &'a [Value],
expected: &str,
) -> Result<&'a std::collections::BTreeMap<String, Value>> {
match args {
[Value::Record(r)] => Ok(r),
[other] => Err(crate::safety::bad_arg(builtin, expected, other.type_name())),
_ => Err(crate::safety::bad_arg(
builtin,
expected,
&format!("{} argument(s)", args.len()),
)),
}
}
/// Refuse a config key the builtin does not read. A misspelt option is an
/// error; read as its default it would be a silently different call.
fn cfg_known_keys(
builtin: &str,
rec: &std::collections::BTreeMap<String, Value>,
known: &[&str],
) -> Result<()> {
if let Some(key) = rec.keys().find(|k| !known.contains(&k.as_str())) {
return Err(crate::safety::bad_arg(
builtin,
&format!("only the keys {}", known.join(", ")),
&format!("unknown key '{key}'"),
));
}
Ok(())
}
fn cfg_str(
builtin: &str,
rec: &std::collections::BTreeMap<String, Value>,
field: &str,
) -> Result<Option<String>> {
match rec.get(field) {
None => Ok(None),
Some(Value::Str(s)) => Ok(Some(s.clone())),
Some(other) => Err(crate::safety::bad_arg(
builtin,
&format!("{field}: a string"),
other.type_name(),
)),
}
}
fn cfg_required_str(
builtin: &str,
rec: &std::collections::BTreeMap<String, Value>,
field: &str,
) -> Result<String> {
cfg_str(builtin, rec, field)?
.ok_or_else(|| crate::safety::bad_arg(builtin, &format!("{field}: a string"), "missing"))
}
fn cfg_bool(
builtin: &str,
rec: &std::collections::BTreeMap<String, Value>,
field: &str,
) -> Result<Option<bool>> {
match rec.get(field) {
None => Ok(None),
Some(Value::Bool(b)) => Ok(Some(*b)),
Some(other) => Err(crate::safety::bad_arg(
builtin,
&format!("{field}: a boolean"),
other.type_name(),
)),
}
}
/// An integer field in `lo..=hi`. A value outside the range is refused, not
/// wrapped by an `as` cast.
fn cfg_int(
builtin: &str,
rec: &std::collections::BTreeMap<String, Value>,
field: &str,
lo: i64,
hi: i64,
) -> Result<Option<i64>> {
match rec.get(field) {
None => Ok(None),
Some(Value::Int(n)) if (lo..=hi).contains(n) => Ok(Some(*n)),
Some(Value::Int(n)) => Err(crate::safety::bad_arg(
builtin,
&format!("{field}: an integer in {lo}..={hi}"),
&n.to_string(),
)),
Some(other) => Err(crate::safety::bad_arg(
builtin,
&format!("{field}: an integer"),
other.type_name(),
)),
}
}
/// A numeric field (Int or Float) in `lo..=hi`, finite. Int is widened, never
/// dropped: the earlier code read only Float and silently ignored Int.
fn cfg_float(
builtin: &str,
rec: &std::collections::BTreeMap<String, Value>,
field: &str,
lo: f64,
hi: f64,
) -> Result<Option<f64>> {
let v = match rec.get(field) {
None => return Ok(None),
Some(Value::Float(f)) => *f,
Some(Value::Int(n)) => *n as f64,
Some(other) => {
return Err(crate::safety::bad_arg(
builtin,
&format!("{field}: a number"),
other.type_name(),
))
}
};
if v.is_finite() && v >= lo && v <= hi {
Ok(Some(v))
} else {
Err(crate::safety::bad_arg(
builtin,
&format!("{field}: a number in {lo}..={hi}"),
&v.to_string(),
))
}
}
// ============ AI Backend Detection ============
/// ai_backends() - Detect and list all available AI backends
/// Returns: Array of records with backend information
///
/// Example:
/// ai_backends() | select(["name", "available", "models"])
fn bi_ai_backends(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
no_args_or_input("ai_backends", &args, &input)?;
// A probe the budget or policy refuses is the answer, not an empty list.
let backends = crate::ai::try_detect_available_backends()?;
let records: Vec<Value> = backends
.into_iter()
.map(|backend| {
let mut record = BTreeMap::new();
record.insert("name".to_string(), Value::Str(backend.name));
record.insert(
"provider".to_string(),
Value::Str(format!("{:?}", backend.provider)),
);
record.insert("endpoint".to_string(), Value::Str(backend.endpoint));
record.insert("available".to_string(), Value::Bool(backend.available));
let models: Vec<Value> = backend.models.into_iter().map(Value::Str).collect();
record.insert("models".to_string(), Value::Array(models));
Value::Record(record)
})
.collect();
Ok(Value::Array(records))
}
/// ai_detect() - Automatically detect and return the best available backend
/// Returns: String with the model URI (e.g., "ollama:llama3" or "vllm:model")
/// Returns empty string if no backend is available.
///
/// Example:
/// let backend = ai_detect()
/// if backend != "" { ai(backend, "Hello!") }
fn bi_ai_detect(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
no_args_or_input("ai_detect", &args, &input)?;
// A probe the budget or policy refuses is an error. "No backend" is the
// empty string, and only that.
match crate::ai::try_auto_select_backend()? {
Some(uri) => Ok(Value::Str(uri)),
None => Ok(Value::Str(String::new())),
}
}
/// ai_usage() - Get detailed AI usage statistics for the current session
/// Returns: Record with call_count, total_tokens, total_cost_usd, and per-call records
///
/// Example:
/// ai_usage()
/// ai_usage() | get("total_cost_usd")
fn bi_ai_usage() -> Result<Value> {
let tracker = crate::ai::COST_TRACKER
.lock()
.map_err(|_| crate::safety::lock_poisoned("ai_usage", "the cost tracker"))?;
let summary = tracker.summary();
Ok(Value::from_json(&summary))
}
/// ai_cost() - Get total estimated cost for the current session in USD
/// Returns: Float (cost in USD)
///
/// Example:
/// ai_cost() # => 0.0042
fn bi_ai_cost() -> Result<Value> {
let tracker = crate::ai::COST_TRACKER
.lock()
.map_err(|_| crate::safety::lock_poisoned("ai_cost", "the cost tracker"))?;
Ok(Value::Float(tracker.total_cost_usd))
}
/// ai_reset_usage() - Reset all usage tracking counters
/// Returns: String confirmation
///
/// Example:
/// ai_reset_usage()
fn bi_ai_reset_usage(input: Option<Value>) -> Result<Value> {
refuse_piped("ai_reset_usage", &input)?;
let mut tracker = crate::ai::COST_TRACKER
.lock()
.map_err(|_| crate::safety::lock_poisoned("ai_reset_usage", "the cost tracker"))?;
tracker.reset();
Ok(Value::Str("Usage tracking reset".to_string()))
}
/// ai_gpu_memory() - Query GPU memory status
/// Returns: Record with GPU memory info for all available GPUs
///
/// Example:
/// ai_gpu_memory()
/// ai_gpu_memory() | get("gpus") | map(fn(g) => g.free_mb)
fn bi_ai_gpu_memory(input: Option<Value>) -> Result<Value> {
refuse_piped("ai_gpu_memory", &input)?;
let summary = crate::ai::try_gpu_memory_summary(None, None, None)?;
Ok(Value::from_json(&summary))
}
/// ai_model_fits(config) - Check if a model fits in GPU memory
/// Args: config record with params_b (float), file_size_mb (int), quantization (string)
/// Returns: Record with model_fits (bool), estimated VRAM, GPU info
///
/// Example:
/// ai_model_fits({params_b: 7.0, quantization: "q4_k_m"})
/// ai_model_fits({file_size_mb: 4096})
fn bi_ai_model_fits(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "ai_model_fits";
refuse_piped(B, &input)?;
let config = cfg_record(
B,
&args,
"a config record: {params_b | file_size_mb, quantization?}",
)?;
cfg_known_keys(B, config, &["params_b", "file_size_mb", "quantization"])?;
// The upper bound keeps the VRAM estimate far inside u64, so the estimate
// cannot overflow; the lower bound refuses a size that rounds to zero MB
// and so "fits" anything.
let params_b = cfg_float(B, config, "params_b", f64::MIN_POSITIVE, 1_000_000.0)?;
let file_size_mb = cfg_int(B, config, "file_size_mb", 1, i64::MAX)?;
let quantization = cfg_str(B, config, "quantization")?;
match (params_b.is_some(), file_size_mb.is_some()) {
(true, false) | (false, true) => {}
(false, false) => {
return Err(crate::safety::bad_arg(
B,
"params_b (billions, above 0) or file_size_mb (1 or more)",
"neither",
))
}
(true, true) => {
return Err(crate::safety::bad_arg(
B,
"one of params_b or file_size_mb",
"both",
))
}
}
let summary = crate::ai::try_gpu_memory_summary(
params_b,
file_size_mb.map(|n| n as u64),
quantization.as_deref(),
)?;
Ok(Value::from_json(&summary))
}
// =========================== AI Provider Registry Builtins ===========================
/// ai_providers() - List all registered providers and their status
/// Returns: Array of records with provider info
///
/// Example:
/// ai_providers()
/// ai_providers() | where(fn(p) => p.status == "ready")
fn bi_ai_providers() -> Result<Value> {
use crate::providers::registry::PROVIDER_REGISTRY;
let mut providers = PROVIDER_REGISTRY.list();
providers.sort_by_key(|provider| provider.scheme());
let available = PROVIDER_REGISTRY.available();
let mut records = Vec::new();
for p in &providers {
let status = PROVIDER_REGISTRY
.status(p)
.map(|s| match s {
crate::providers::registry::ProviderStatus::Ready => "ready".to_string(),
crate::providers::registry::ProviderStatus::Unchecked => "unchecked".to_string(),
crate::providers::registry::ProviderStatus::Unavailable { reason } => {
format!("unavailable: {}", reason)
}
crate::providers::registry::ProviderStatus::AuthFailed { reason } => {
format!("auth_failed: {}", reason)
}
crate::providers::registry::ProviderStatus::Disabled => "disabled".to_string(),
})
.unwrap_or_else(|| "unknown".to_string());
let is_available = available.contains(p);
let is_default = PROVIDER_REGISTRY.default_provider() == Some(*p);
let mut rec = std::collections::BTreeMap::new();
rec.insert("name".to_string(), Value::Str(p.scheme().to_string()));
rec.insert("status".to_string(), Value::Str(status));
rec.insert("available".to_string(), Value::Bool(is_available));
rec.insert("default".to_string(), Value::Bool(is_default));
if let Some(env_key) = p.api_key_env_var() {
rec.insert(
"has_key".to_string(),
Value::Bool(std::env::var(env_key).is_ok()),
);
} else if matches!(p, crate::providers::ProviderType::Bedrock) {
// Bedrock is refused (no SigV4 signing), so it has no usable key.
rec.insert("has_key".to_string(), Value::Bool(false));
rec.insert("available".to_string(), Value::Bool(false));
} else {
rec.insert("has_key".to_string(), Value::Bool(true));
}
records.push(Value::Record(rec));
}
Ok(Value::Array(records))
}
/// ai_routes() - List all configured routing rules
/// Returns: Array of records with rule info
///
/// Example:
/// ai_routes()
fn bi_ai_routes() -> Result<Value> {
// The rules the registry checks, in the order it checks them, and the
// registry's totals. Nothing here contacts a provider.
use crate::providers::registry::PROVIDER_REGISTRY;
let stats = PROVIDER_REGISTRY.stats();
let providers = PROVIDER_REGISTRY.list();
let rules = PROVIDER_REGISTRY.routing_rules();
let rule_values: Vec<Value> = rules
.iter()
.map(|rule| {
let mut r = std::collections::BTreeMap::new();
r.insert("name".to_string(), Value::Str(rule.name.clone()));
r.insert("priority".to_string(), Value::Int(i64::from(rule.priority)));
r.insert(
"condition".to_string(),
Value::Str(rule.condition.describe()),
);
// A rule with no target matches but returns nothing, so route
// skips it: Null says so instead of guessing a provider.
r.insert(
"target".to_string(),
rule.target_provider
.map(|p| Value::Str(p.scheme().to_string()))
.unwrap_or(Value::Null),
);
r.insert(
"allow_fallback".to_string(),
Value::Bool(rule.allow_fallback),
);
Value::Record(r)
})
.collect();
let mut rec = std::collections::BTreeMap::new();
rec.insert("rule_count".to_string(), Value::Int(rules.len() as i64));
if rules.is_empty() {
rec.insert(
"note".to_string(),
Value::Str(
"no routing rules are registered; a request goes to the provider its model names when that provider is registered, and to the default provider otherwise"
.to_string(),
),
);
}
rec.insert("rules".to_string(), Value::Array(rule_values));
rec.insert(
"total_requests".to_string(),
Value::Int(stats.total_requests as i64),
);
rec.insert(
"total_tokens".to_string(),
Value::Int(stats.total_tokens as i64),
);
rec.insert(
"registered_providers".to_string(),
Value::Int(providers.len() as i64),
);
rec.insert(
"default_provider".to_string(),
PROVIDER_REGISTRY
.default_provider()
.map(|p| Value::Str(p.scheme().to_string()))
.unwrap_or(Value::Str("none".to_string())),
);
rec.insert(
"router_enabled".to_string(),
Value::Bool(std::env::var("IRONSHELL_AI_ROUTER").ok().as_deref() == Some("registry")),
);
Ok(Value::Record(rec))
}
/// ai_add_route(config) - Add a routing rule
/// Args: config record with name, priority (int), condition (string), target (string), allow_fallback (bool)
/// Condition types: "always", "has_tools", "has_vision", "model:pattern", "tokens_over:N"
/// Returns: String confirmation
///
/// Example:
/// ai_add_route({name: "tools_to_openai", priority: 100, condition: "has_tools", target: "openai"})
/// ai_add_route({name: "large_to_groq", priority: 50, condition: "tokens_over:4000", target: "groq"})
fn bi_ai_add_route(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
use crate::providers::registry::{RoutingCondition, RoutingRule, PROVIDER_REGISTRY};
use crate::providers::ProviderType;
const B: &str = "ai_add_route";
refuse_piped(B, &input)?;
let config = cfg_record(
B,
&args,
"a config record: {name, condition, priority?, target?, allow_fallback?}",
)?;
cfg_known_keys(
B,
config,
&["name", "priority", "condition", "target", "allow_fallback"],
)?;
let name = cfg_required_str(B, config, "name")?;
let condition_str = cfg_required_str(B, config, "condition")?;
let priority = cfg_int(B, config, "priority", 0, u32::MAX as i64)?.map_or(50, |p| p as u32);
let target_str = cfg_str(B, config, "target")?;
let allow_fallback = cfg_bool(B, config, "allow_fallback")?.unwrap_or(true);
let condition = if condition_str == "always" {
RoutingCondition::Always
} else if condition_str == "has_tools" {
RoutingCondition::HasTools
} else if condition_str == "has_vision" {
RoutingCondition::HasVision
} else if let Some(pattern) = condition_str.strip_prefix("model:") {
RoutingCondition::ModelPattern {
pattern: pattern.to_string(),
}
} else if let Some(n) = condition_str.strip_prefix("tokens_over:") {
let threshold = n.parse::<u32>().map_err(|_| {
crate::safety::bad_arg(
B,
"condition: tokens_over:<N> with N a whole number",
&condition_str,
)
})?;
RoutingCondition::TokenCountOver { threshold }
} else {
return Err(crate::safety::bad_arg(
B,
"condition: always, has_tools, has_vision, model:<pattern> or tokens_over:<N>",
&condition_str,
));
};
let target_provider = match target_str.as_deref() {
None => None,
Some(target) => Some(ProviderType::from_scheme(target).map_err(|_| {
crate::safety::bad_arg(B, "target: a provider name such as openai", target)
})?),
};
let rule = RoutingRule {
name: name.clone(),
priority,
condition,
target_provider,
allow_fallback,
};
PROVIDER_REGISTRY.add_routing_rule(rule);
Ok(Value::Str(format!(
"Route '{}' added (priority {})",
name, priority
)))
}
/// ai_set_default(provider) - Set the default AI provider for registry routing
/// Args: provider name string (e.g., "openai", "anthropic", "ollama")
/// Returns: String confirmation
///
/// Example:
/// ai_set_default("openai")
fn bi_ai_set_default(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
use crate::providers::registry::PROVIDER_REGISTRY;
use crate::providers::ProviderType;
const B: &str = "ai_set_default";
refuse_piped(B, &input)?;
let provider_str = sole_str(B, &args, "a provider name string")?;
// Refused before the registry is touched: an unknown name changes nothing.
let provider_type = ProviderType::from_scheme(&provider_str).map_err(|_| {
crate::safety::bad_arg(B, "a provider name such as openai or ollama", &provider_str)
})?;
// Register if not already registered
if PROVIDER_REGISTRY.get(&provider_type).is_none() {
let config = crate::providers::ProviderConfig::from_env(provider_type).with_env_key();
PROVIDER_REGISTRY.register(provider_type, config);
}
// Registered just above, so the only failure is a provider the registry
// does not hold; say which name missed.
PROVIDER_REGISTRY
.set_default(provider_type)
.map_err(|_| crate::safety::not_found(B, "provider", &provider_str))?;
Ok(Value::Str(format!(
"Default provider set to '{}'",
provider_str
)))
}
/// ai_registry_stats() - Get usage statistics from the provider registry
/// Returns: Record with per-provider request/token/success counts
///
/// Example:
/// ai_registry_stats()
fn bi_ai_registry_stats() -> Result<Value> {
use crate::providers::registry::PROVIDER_REGISTRY;
let stats = PROVIDER_REGISTRY.stats();
let mut rec = std::collections::BTreeMap::new();
rec.insert(
"total_requests".to_string(),
Value::Int(stats.total_requests as i64),
);
rec.insert(
"total_tokens".to_string(),
Value::Int(stats.total_tokens as i64),
);
let mut by_provider = std::collections::BTreeMap::new();
for (pt, ps) in &stats.by_provider {
let mut prec = std::collections::BTreeMap::new();
prec.insert("requests".to_string(), Value::Int(ps.requests as i64));
prec.insert("successes".to_string(), Value::Int(ps.successes as i64));
prec.insert("failures".to_string(), Value::Int(ps.failures as i64));
prec.insert("tokens".to_string(), Value::Int(ps.tokens as i64));
prec.insert(
"avg_latency_ms".to_string(),
Value::Float(ps.avg_latency_ms),
);
prec.insert(
"min_latency_ms".to_string(),
Value::Float(ps.min_latency_ms),
);
prec.insert(
"max_latency_ms".to_string(),
Value::Float(ps.max_latency_ms),
);
prec.insert(
"p95_latency_ms".to_string(),
Value::Float(ps.p95_latency_ms),
);
prec.insert(
"total_cost_usd".to_string(),
Value::Float(ps.total_cost_usd),
);
by_provider.insert(pt.scheme().to_string(), Value::Record(prec));
}
rec.insert("by_provider".to_string(), Value::Record(by_provider));
// Include load balancing strategy
let strategy = PROVIDER_REGISTRY.load_balancing_strategy();
rec.insert(
"load_balancing".to_string(),
Value::Str(format!("{:?}", strategy)),
);
Ok(Value::Record(rec))
}
/// ai_set_load_balancing(strategy) - Set the load balancing strategy for AI providers
/// Args: strategy string: "none", "round_robin", "least_latency", "least_requests", "adaptive"
/// OR a record with {type: "weighted", weights: {openai: 3, anthropic: 1}}
/// Returns: String confirmation
///
/// Example:
/// ai_set_load_balancing("round_robin")
/// ai_set_load_balancing("least_latency")
/// ai_set_load_balancing({type: "weighted", weights: {openai: 3, anthropic: 1}})
fn bi_ai_set_load_balancing(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
use crate::providers::registry::{LoadBalancingStrategy, PROVIDER_REGISTRY};
const B: &str = "ai_set_load_balancing";
refuse_piped(B, &input)?;
let strategy = match args.as_slice() {
[Value::Str(s)] => match s.as_str() {
"none" => LoadBalancingStrategy::None,
"round_robin" | "roundrobin" | "rr" => LoadBalancingStrategy::RoundRobin,
"least_latency" | "latency" => LoadBalancingStrategy::LeastLatency,
"least_requests" | "requests" => LoadBalancingStrategy::LeastRequests,
"adaptive" | "auto" => LoadBalancingStrategy::Adaptive,
other => {
return Err(crate::safety::bad_arg(
B,
"a strategy: none, round_robin, least_latency, least_requests, adaptive, or a weighted config record",
other,
))
}
},
[Value::Record(r)] => weighted_strategy(B, r)?,
[other] => {
return Err(crate::safety::bad_arg(
B,
"a strategy string or a weighted config record",
other.type_name(),
))
}
_ => {
return Err(crate::safety::bad_arg(
B,
"one strategy argument",
&format!("{} argument(s)", args.len()),
))
}
};
let label = format!("{:?}", strategy);
PROVIDER_REGISTRY.set_load_balancing(strategy);
Ok(Value::Str(format!("Load balancing set to {}", label)))
}
/// The weighted strategy a config record names. Every key and weight is
/// checked: a misspelt key, a non-integer weight or an empty table is refused,
/// rather than read as weight 1 or as a strategy that does nothing.
fn weighted_strategy(
builtin: &str,
r: &std::collections::BTreeMap<String, Value>,
) -> Result<crate::providers::registry::LoadBalancingStrategy> {
use crate::providers::registry::LoadBalancingStrategy;
use crate::providers::ProviderType;
cfg_known_keys(builtin, r, &["type", "weights"])?;
let lb_type = cfg_str(builtin, r, "type")?.unwrap_or_else(|| "weighted".to_string());
if lb_type != "weighted" {
return Err(crate::safety::bad_arg(
builtin,
"type: \"weighted\"",
&lb_type,
));
}
let weights_rec = match r.get("weights") {
Some(Value::Record(w)) => w,
Some(other) => {
return Err(crate::safety::bad_arg(
builtin,
"weights: a record of provider name to weight",
other.type_name(),
))
}
None => {
return Err(crate::safety::bad_arg(
builtin,
"weights: a record of provider name to weight",
"missing",
))
}
};
if weights_rec.is_empty() {
return Err(crate::safety::bad_arg(
builtin,
"weights: at least one provider",
"an empty record",
));
}
let mut weights = std::collections::HashMap::new();
for (key, value) in weights_rec {
let provider = ProviderType::from_scheme(key).map_err(|_| {
crate::safety::bad_arg(
builtin,
"weights: keys are provider names such as openai",
key,
)
})?;
let weight = match value {
Value::Int(i) if (0..=u32::MAX as i64).contains(i) => *i as u32,
Value::Float(f)
if f.is_finite() && f.fract() == 0.0 && *f >= 0.0 && *f <= u32::MAX as f64 =>
{
*f as u32
}
other => {
return Err(crate::safety::bad_arg(
builtin,
&format!("weight for {key}: a whole number from 0 to {}", u32::MAX),
&other.to_string(),
))
}
};
weights.insert(provider, weight);
}
Ok(LoadBalancingStrategy::Weighted { weights })
}
/// ai_load_balancing() - Get the current load balancing strategy
/// Returns: String describing the active strategy
///
/// Example:
/// ai_load_balancing()
fn bi_ai_load_balancing() -> Result<Value> {
use crate::providers::registry::PROVIDER_REGISTRY;
let strategy = PROVIDER_REGISTRY.load_balancing_strategy();
Ok(Value::Str(format!("{:?}", strategy)))
}
/// ai_convert_model(config) - Convert a model between formats.
///
/// **Delegates to IronVault.** This used to call an in-tree converter whose
/// every path was `fs::copy` — its own source said "simulate conversion by
/// copying file" — and which then reported `success: true` with a checksum of
/// the unchanged bytes. A conversion that silently does nothing is worse than
/// one that fails, so that path is gone. Format conversion is IronVault's job
/// in this stack, and IronVault addresses models by vault name rather than by
/// file path, so callers holding a loose file must add it to the vault first.
///
/// Args: config record with name and to_format (see `vault_convert`), or the
/// legacy source/target/source_format/target_format form, which is refused.
///
/// Example:
/// ai_convert_model({name: "my-llm", to_format: "gguf", quantization: "q4_k_m"})
fn bi_ai_convert_model(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "ai_convert_model";
refuse_piped(B, &input)?;
let config = cfg_record(
B,
&args,
"a config record: {name, to_format, quantization?, output?, validate?}",
)?;
// The legacy path-based form cannot be honoured: IronVault converts models
// it stores, and there is no correct way to guess which stored model a
// loose path corresponds to. Say so precisely rather than half-working.
if config.contains_key("source") || config.contains_key("target") {
return Err(crate::safety::arg_err(
"ai_convert_model: the source/target path form is no longer supported.\n\
It was backed by an in-tree converter that copied the file and reported \
success, so it never converted anything.\n\
Conversion now goes through IronVault, which addresses models by vault name:\n \
iv add <path> --name <name>\n \
ai_convert_model({name: \"<name>\", to_format: \"gguf\"})",
));
}
bi_vault_convert(args, None)
}
/// ai_supported_conversions() - List the format conversions available.
///
/// Answered by IronVault, which is what actually performs them. The in-tree
/// table this used to read listed conversions that were implemented as file
/// copies, so it described capability the shell did not have.
///
/// Returns: the vault's supported conversion paths
fn bi_ai_supported_conversions(input: Option<Value>) -> Result<Value> {
bi_vault_conversions(input)
}
/// ai_detect_format(path) - Detect the format of a model file
/// Args: path (string)
/// Returns: String with detected format name
fn bi_ai_detect_format(args: Vec<Value>) -> Result<Value> {
let path = match args.first() {
Some(Value::Str(s)) => s,
_ => {
return Err(crate::safety::arg_err(
"ai_detect_format: requires a file path string",
))
}
};
let ext = std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
let format = match ext.to_lowercase().as_str() {
"gguf" => "GGUF",
"safetensors" => "SafeTensors",
"pt" | "pth" | "bin" => "PyTorch",
"onnx" => "ONNX",
"pb" | "savedmodel" => "TensorFlow",
"trt" | "engine" => "TensorRT",
_ => "Unknown",
};
Ok(Value::Str(format.to_string()))
}
// ========== Local Inference Builtins ==========
// ============================================================================
// THE NERVOSYS STACK — IronGate (routing), IronWorks (inference),
// IronVault (model store and conversion)
// ============================================================================
/// ai_gateway() - Report IronGate: whether it is up, what it will route, and
/// the state of each backend behind it.
///
/// This is the one place a caller can see the shape of the stack it is talking
/// to, including whether the local leg (IronWorks) is actually serving.
///
/// Returns: Record with url, reachable, providers, models, targets
///
/// Example:
/// ai_gateway()
/// ai_gateway().targets | where(fn(t) => t.circuit != "closed")
fn bi_ai_gateway(input: Option<Value>) -> Result<Value> {
refuse_piped("ai_gateway", &input)?;
let mut rec = std::collections::BTreeMap::new();
rec.insert(
"url".to_string(),
Value::Str(crate::model_plane::gate_url()),
);
rec.insert(
"default_model".to_string(),
Value::Str(crate::model_plane::gate_model()),
);
let health = match crate::model_plane::gate_health() {
Ok(h) => h,
Err(e) => {
// An unreachable gateway is an answer, not an error: the caller
// asked about the gateway's state and "down" is a state. Returning
// Err here would make the common check `ai_gateway().reachable`
// impossible to write.
rec.insert("reachable".to_string(), Value::Bool(false));
rec.insert("error".to_string(), Value::Str(e.to_string()));
rec.insert("providers".to_string(), Value::Int(0));
rec.insert("models".to_string(), Value::Array(vec![]));
rec.insert("targets".to_string(), Value::Array(vec![]));
return Ok(Value::Record(rec));
}
};
rec.insert("reachable".to_string(), Value::Bool(true));
rec.insert("providers".to_string(), Value::Int(health.providers as i64));
rec.insert(
"models".to_string(),
Value::Array(health.models.into_iter().map(Value::Str).collect()),
);
// Per-target circuit state. Best-effort: /status is a richer endpoint than
// /health and a gateway can be serving with it unavailable. When it is
// unavailable the targets are unknown, and the reason is reported as
// targets_error rather than presented as an empty list.
let targets = match crate::model_plane::gate_status() {
Ok(status) => status
.get("targets")
.or_else(|| status.get("breakers"))
.and_then(|t| t.as_array())
.map(|arr| {
arr.iter()
.cloned()
.map(json_to_value)
.collect::<Vec<Value>>()
})
.unwrap_or_default(),
Err(e) => {
rec.insert("targets_error".to_string(), Value::Str(e.to_string()));
vec![]
}
};
rec.insert("targets".to_string(), Value::Array(targets));
Ok(Value::Record(rec))
}
/// vault_models() - List the models IronVault is storing.
///
/// Reads the encrypted inventory, so IRONVAULT_PASSPHRASE must be set; the
/// vault reports that failure itself rather than this builtin guessing at it.
///
/// Returns: Array of records, as the vault reports them
///
/// Example:
/// vault_models()
/// vault_models() | where(fn(m) => m.format == "gguf")
fn bi_vault_models(input: Option<Value>) -> Result<Value> {
refuse_piped("vault_models", &input)?;
let json = crate::model_plane::vault_list()?;
Ok(json_to_value(json))
}
/// vault_conversions() - List the format conversions IronVault supports.
///
/// Returns: Array of supported conversion paths
fn bi_vault_conversions(input: Option<Value>) -> Result<Value> {
refuse_piped("vault_conversions", &input)?;
let json = crate::model_plane::vault_conversions()?;
Ok(json_to_value(json))
}
/// vault_convert(config) - Convert a stored model between formats.
///
/// Args: config record with name, to_format, and optionally quantization,
/// output, validate.
///
/// Returns: Record with the vault's own report
///
/// Example:
/// vault_convert({name: "my-llm", to_format: "gguf", quantization: "q4_k_m", validate: true})
fn bi_vault_convert(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "vault_convert";
refuse_piped(B, &input)?;
let config = cfg_record(
B,
&args,
"a config record: {name, to_format, quantization?, output?, validate?}",
)?;
cfg_known_keys(
B,
config,
&[
"name",
"to_format",
"target_format",
"quantization",
"output",
"validate",
],
)?;
let name = cfg_required_str(B, config, "name")?;
let to_format = match (
cfg_str(B, config, "to_format")?,
cfg_str(B, config, "target_format")?,
) {
(Some(f), None) | (None, Some(f)) => f,
(None, None) => {
return Err(crate::safety::bad_arg(
B,
"to_format: a format such as \"gguf\" or \"safetensors\"",
"missing",
))
}
(Some(_), Some(_)) => {
return Err(crate::safety::bad_arg(
B,
"one of to_format or target_format",
"both",
))
}
};
let quantization = cfg_str(B, config, "quantization")?;
let output = cfg_str(B, config, "output")?;
let validate = cfg_bool(B, config, "validate")?.unwrap_or(false);
// These reach iv as argv, where a value beginning with '-' is an option.
let mut values = vec![name.clone(), to_format.clone()];
values.extend(quantization.iter().cloned());
values.extend(output.iter().cloned());
crate::safety::reject_option_like(B, &values)?;
let stdout = crate::model_plane::vault_convert(
&name,
&to_format,
quantization.as_deref(),
output.as_deref(),
validate,
)?;
let mut rec = std::collections::BTreeMap::new();
rec.insert("name".to_string(), Value::Str(name));
rec.insert("to_format".to_string(), Value::Str(to_format));
if let Some(q) = quantization {
rec.insert("quantization".to_string(), Value::Str(q));
}
if let Some(o) = output {
rec.insert("output".to_string(), Value::Str(o));
}
rec.insert("validated".to_string(), Value::Bool(validate));
rec.insert("report".to_string(), Value::Str(stdout.trim().to_string()));
Ok(Value::Record(rec))
}
/// ai_local_backends() - List available local inference backends
/// Returns: Array of Records with backend name and supported formats
///
/// Example:
/// ai_local_backends()
fn bi_ai_local_backends() -> Result<Value> {
use crate::local_inference;
let backends = local_inference::available_backends();
let names = local_inference::backend_names();
if backends.is_empty() {
return Ok(Value::Array(vec![Value::Record({
let mut r = std::collections::BTreeMap::new();
r.insert("status".to_string(), Value::Str("none".to_string()));
r.insert(
"message".to_string(),
Value::Str(
"No local inference backends compiled. Rebuild with --features candle or --features onnx"
.to_string(),
),
);
r
})]));
}
let mut result = Vec::new();
for (backend, name) in backends.iter().zip(names.iter()) {
let mut rec = std::collections::BTreeMap::new();
rec.insert("name".to_string(), Value::Str(name.to_string()));
rec.insert(
"loaded_models".to_string(),
Value::Array(
backend
.loaded_models()
.into_iter()
.map(Value::Str)
.collect(),
),
);
let formats: Vec<&str> = ["gguf", "safetensors", "onnx", "pt", "bin"]
.iter()
.copied()
.filter(|ext| backend.supports_format(ext))
.collect();
rec.insert(
"formats".to_string(),
Value::Array(
formats
.into_iter()
.map(|f| Value::Str(f.to_string()))
.collect(),
),
);
result.push(Value::Record(rec));
}
Ok(Value::Array(result))
}
/// ai_local_load(path) - Load a local model file into an inference backend
/// Returns: Record with handle, backend name, and estimated memory
///
/// Example:
/// ai_local_load("models/llama-3.2-1b-q4_k_m.gguf")
fn bi_ai_local_load(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
use crate::local_inference;
const B: &str = "ai_local_load";
refuse_piped(B, &input)?;
let path = sole_str(B, &args, "a model file path string")?;
let backend = local_inference::backend_for_model(&path).ok_or_else(|| {
crate::safety::unimplemented(
B,
&format!(
"no compiled-in backend supports this model format (available: {:?})",
local_inference::backend_names()
),
"rebuild with --features candle (for .gguf/.safetensors) or --features onnx (for .onnx)",
)
})?;
let mut rec = std::collections::BTreeMap::new();
// A memory estimate the backend cannot make is unknown, and the reason is
// reported with it. It is not zero MB.
match backend.estimate_memory_mb(&path) {
Ok(mb) => {
rec.insert("estimated_memory_mb".to_string(), Value::Int(mb as i64));
}
Err(e) => {
rec.insert("estimated_memory_mb".to_string(), Value::Null);
rec.insert("estimate_error".to_string(), Value::Str(e.to_string()));
}
}
// A backend's coded refusal (an unsupported architecture, say) and a
// file it cannot open keep their own codes; only what is left is read as
// a file the backend cannot load.
let handle = backend.load_model(&path).map_err(|e| {
if e.downcast_ref::<crate::safety::SafetyError>().is_some() {
return e;
}
if let Some(io) = e.downcast_ref::<std::io::Error>() {
return crate::safety::fs_error(B, &path, io);
}
crate::safety::bad_arg(B, "a model file the backend can load", &e.to_string())
})?;
rec.insert("handle".to_string(), Value::Str(handle));
rec.insert(
"backend".to_string(),
Value::Str(backend.name().to_string()),
);
rec.insert("path".to_string(), Value::Str(path));
Ok(Value::Record(rec))
}
/// The prompt `ai_local_generate` was given: text for the backend's `generate`,
/// or chat messages for its `chat`.
enum GeneratePrompt {
Text(String),
Chat(Vec<crate::local_inference::ChatMessage>),
}
/// What `ai_local_generate` takes as its prompt, for a refusal to quote.
const GENERATE_PROMPT_EXPECTED: &str = "prompt: a String, or an Array of {role, content} messages";
/// The messages of a chat prompt: an Array of Records with exactly `role` and
/// `content`, both Strings, and a role of system, user, assistant or tool. Every
/// message is checked here, before a backend is looked up, so a malformed
/// conversation is refused the same way whichever model it names.
fn chat_messages_arg(
builtin: &str,
items: &[Value],
) -> Result<Vec<crate::local_inference::ChatMessage>> {
if items.is_empty() {
return Err(crate::safety::bad_arg(
builtin,
GENERATE_PROMPT_EXPECTED,
"an empty Array",
));
}
let mut messages = Vec::with_capacity(items.len());
for (i, item) in items.iter().enumerate() {
let n = i + 1;
let Value::Record(rec) = item else {
return Err(crate::safety::bad_arg(
builtin,
GENERATE_PROMPT_EXPECTED,
&format!("message {n} of type {}", item.type_name()),
));
};
if rec.len() != 2 || !rec.contains_key("role") || !rec.contains_key("content") {
let fields: Vec<&str> = rec.keys().map(String::as_str).collect();
return Err(crate::safety::bad_arg(
builtin,
GENERATE_PROMPT_EXPECTED,
&format!("message {n} with fields {fields:?}"),
));
}
let (role, content) = match (rec.get("role"), rec.get("content")) {
(Some(Value::Str(role)), Some(Value::Str(content))) => (role, content),
_ => {
return Err(crate::safety::bad_arg(
builtin,
GENERATE_PROMPT_EXPECTED,
&format!("message {n} whose role and content are not both Strings"),
))
}
};
if !matches!(role.as_str(), "system" | "user" | "assistant" | "tool") {
return Err(crate::safety::bad_arg(
builtin,
GENERATE_PROMPT_EXPECTED,
&format!("message {n} with role {role:?}"),
));
}
messages.push(crate::local_inference::ChatMessage {
role: role.clone(),
content: content.clone(),
});
}
Ok(messages)
}
/// ai_local_generate(handle, prompt, params?) - Generate text using a loaded local model
/// The prompt is a String, sent as is, or an Array of {role, content} messages
/// (role: system, user, assistant or tool). A messages Array is formatted by the
/// model's own chat template, and a model with no chat template refuses it.
/// Returns: Record with generated text, token counts, and performance metrics
///
/// Example:
/// ai_local_generate("candle:llama", "What is Rust?")
/// ai_local_generate("candle:llama", [{role: "user", content: "What is Rust?"}])
/// ai_local_generate("candle:llama", "Hello", {max_tokens: 100, temperature: 0.5})
fn bi_ai_local_generate(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
use crate::local_inference::{self, GenerationParams};
const B: &str = "ai_local_generate";
refuse_piped(B, &input)?;
let handle = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(crate::safety::arg_err(
"ai_local_generate: requires handle string as first argument",
))
}
};
let prompt = match args.get(1) {
Some(Value::Str(s)) => GeneratePrompt::Text(s.clone()),
Some(Value::Array(items)) => GeneratePrompt::Chat(chat_messages_arg(B, items)?),
Some(other) => {
return Err(crate::safety::bad_arg(
B,
GENERATE_PROMPT_EXPECTED,
other.type_name(),
))
}
None => {
return Err(crate::safety::arg_err(
"ai_local_generate: requires prompt as second argument",
))
}
};
let mut params = GenerationParams::default();
match args.get(2) {
None => {}
Some(Value::Record(r)) => {
cfg_known_keys(
B,
r,
&[
"max_tokens",
"temperature",
"top_p",
"repetition_penalty",
"seed",
],
)?;
if let Some(n) = cfg_int(B, r, "max_tokens", 1, u32::MAX as i64)? {
params.max_tokens = n as u32;
}
if let Some(t) = cfg_float(B, r, "temperature", 0.0, f64::MAX)? {
params.temperature = t;
}
if let Some(p) = cfg_float(B, r, "top_p", 0.0, 1.0)? {
params.top_p = p;
}
if let Some(p) = cfg_float(B, r, "repetition_penalty", f64::MIN_POSITIVE, f64::MAX)? {
params.repetition_penalty = p;
}
if let Some(s) = cfg_int(B, r, "seed", 0, i64::MAX)? {
params.seed = Some(s as u64);
}
}
Some(other) => {
return Err(crate::safety::bad_arg(
B,
"params: a record {max_tokens?, temperature?, top_p?, repetition_penalty?, seed?}",
other.type_name(),
))
}
}
// Find the right backend from the handle prefix
for backend in local_inference::available_backends() {
if handle.starts_with(&format!("{}:", backend.name())) {
let result = match &prompt {
GeneratePrompt::Text(text) => backend.generate(&handle, text, ¶ms)?,
GeneratePrompt::Chat(messages) => backend.chat(&handle, messages, ¶ms)?,
};
let mut rec = std::collections::BTreeMap::new();
rec.insert("text".to_string(), Value::Str(result.text));
rec.insert(
"prompt_tokens".to_string(),
Value::Int(result.prompt_tokens as i64),
);
rec.insert(
"completion_tokens".to_string(),
Value::Int(result.completion_tokens as i64),
);
rec.insert(
"generation_ms".to_string(),
Value::Float(result.generation_ms),
);
rec.insert(
"tokens_per_second".to_string(),
Value::Float(result.tokens_per_second),
);
rec.insert(
"backend".to_string(),
Value::Str(backend.name().to_string()),
);
return Ok(Value::Record(rec));
}
}
// Nothing in-process can serve this handle. Before failing, try the
// gateway: in this stack "local inference" means IronWorks behind IronGate,
// and a build with neither `candle` nor `onnx` compiled in — which is the
// default build — has no in-process backend at all. Treat the handle as the
// model name and let the gateway route it.
// The gateway takes a prompt string, so messages have no route through it.
let prompt = match prompt {
GeneratePrompt::Text(text) => text,
GeneratePrompt::Chat(_) if crate::model_plane::gate_available() => {
return Err(crate::safety::unimplemented(
B,
"chat messages through IronGate",
"pass a prompt string",
))
}
GeneratePrompt::Chat(_) => {
return Err(crate::safety::not_found(B, "local model handle", &handle))
}
};
if crate::model_plane::gate_available() {
let max_tokens = Some(params.max_tokens);
let done = crate::model_plane::gate_complete(&handle, &prompt, max_tokens)?;
let mut rec = std::collections::BTreeMap::new();
rec.insert("text".to_string(), Value::Str(done.text));
rec.insert(
"prompt_tokens".to_string(),
Value::Int(done.prompt_tokens as i64),
);
rec.insert(
"completion_tokens".to_string(),
Value::Int(done.completion_tokens as i64),
);
rec.insert("generation_ms".to_string(), Value::Float(done.elapsed_ms));
rec.insert(
"tokens_per_second".to_string(),
Value::Float(if done.elapsed_ms > 0.0 {
done.completion_tokens as f64 / (done.elapsed_ms / 1000.0)
} else {
0.0
}),
);
// Which engine answered is part of the answer here: the same call can
// be served in-process or by a machine across the network, and a caller
// comparing timings needs to know which.
rec.insert(
"backend".to_string(),
Value::Str(done.target.unwrap_or_else(|| "irongate".to_string())),
);
return Ok(Value::Record(rec));
}
Err(crate::safety::not_found(B, "local model handle", &handle))
}
/// ai_local_embed(handle, inputs) - Generate embeddings using a loaded local model
/// Returns: Record with embeddings array and token count
///
/// Example:
/// ai_local_embed("candle:embed-model", ["Hello world", "Rust is great"])
fn bi_ai_local_embed(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
use crate::local_inference;
const B: &str = "ai_local_embed";
refuse_piped(B, &input)?;
let handle = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(crate::safety::arg_err(
"ai_local_embed: requires handle string as first argument",
))
}
};
// Every element must be a string. The earlier code rendered a number or a
// record with to_string and embedded that text, which is a different call.
let inputs: Vec<String> = match args.get(1) {
Some(Value::Array(arr)) if arr.is_empty() => {
return Err(crate::safety::bad_arg(
B,
"inputs: at least one string",
"an empty array",
))
}
Some(Value::Array(arr)) => arr
.iter()
.map(|v| match v {
Value::Str(s) => Ok(s.clone()),
other => Err(crate::safety::bad_arg(
B,
"inputs: an array of strings",
other.type_name(),
)),
})
.collect::<Result<Vec<String>>>()?,
Some(Value::Str(s)) => vec![s.clone()],
_ => {
return Err(crate::safety::arg_err(
"ai_local_embed: requires input string or array of strings",
))
}
};
for backend in local_inference::available_backends() {
if handle.starts_with(&format!("{}:", backend.name())) {
let result = backend.embed(&handle, &inputs)?;
let mut rec = std::collections::BTreeMap::new();
rec.insert(
"embeddings".to_string(),
Value::Array(
result
.embeddings
.into_iter()
.map(|emb| {
Value::Array(emb.into_iter().map(|f| Value::Float(f as f64)).collect())
})
.collect(),
),
);
rec.insert(
"total_tokens".to_string(),
Value::Int(result.total_tokens as i64),
);
return Ok(Value::Record(rec));
}
}
Err(crate::safety::not_found(B, "local model handle", &handle))
}
/// ai_local_unload(handle) - Unload a previously loaded local model
/// Returns: String confirmation
///
/// Example:
/// ai_local_unload("candle:llama")
fn bi_ai_local_unload(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
use crate::local_inference;
const B: &str = "ai_local_unload";
refuse_piped(B, &input)?;
let handle = sole_str(B, &args, "a handle string")?;
for backend in local_inference::available_backends() {
if handle.starts_with(&format!("{}:", backend.name())) {
backend.unload_model(&handle)?;
return Ok(Value::Str(format!("Model '{}' unloaded", handle)));
}
}
Err(crate::safety::not_found(B, "local model handle", &handle))
}
// ========== Runtime Type Introspection ==========
/// typeof(value) - Get the runtime type name of a value
/// Returns: String with the type name
///
/// Example:
/// typeof(42) # => "Int"
/// typeof("hello") # => "String"
/// typeof([1,2]) # => "Array"
/// typeof({a: 1}) # => "Record"
/// 42 | typeof() # => "Int"
fn bi_typeof(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let val = if let Some(input) = input {
input
} else if let Some(v) = args.first() {
v.clone()
} else {
return Err(crate::safety::arg_err(
"typeof: requires a value (argument or piped input)",
));
};
Ok(Value::Str(val.type_name().to_string()))
}
/// type_name(value) - Get detailed type description with inner types
/// Returns: String with detailed type info
///
/// Example:
/// type_name([1,2,3]) # => "Array<Int>"
/// type_name({a: 1, b: "x"}) # => "Record{a: Int, b: String}"
fn bi_type_name(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let val = if let Some(input) = input {
input
} else if let Some(v) = args.first() {
v.clone()
} else {
return Err(crate::safety::arg_err("type_name: requires a value"));
};
let type_desc = detailed_type_name(&val);
Ok(Value::Str(type_desc))
}
fn detailed_type_name(val: &Value) -> String {
match val {
Value::Int(_) => "Int".into(),
Value::Float(_) => "Float".into(),
Value::Str(_) => "String".into(),
Value::Bool(_) => "Bool".into(),
Value::Null => "Null".into(),
Value::Uri(_) => "Uri".into(),
Value::Error(e) => format!("Error({})", e),
Value::Builtin(b) => format!("Builtin({})", b.name),
Value::AsyncLambda(al) => format!("AsyncLambda({})", al.params.len()),
Value::Future(_) => "Future".into(),
Value::Array(items) => {
if items.is_empty() {
"Array<Any>".into()
} else {
let inner = detailed_type_name(&items[0]);
format!("Array<{}>", inner)
}
}
Value::Record(fields) => {
let parts: Vec<String> = fields
.iter()
.take(6)
.map(|(k, v)| format!("{}: {}", k, detailed_type_name(v)))
.collect();
let suffix = if fields.len() > 6 { ", …" } else { "" };
format!("Record{{{}{}}}", parts.join(", "), suffix)
}
Value::Lambda(l) => format!("Lambda({})", l.params.len()),
Value::Table(t) => {
format!("Table({} cols, {} rows)", t.schema.len(), t.rows.len())
}
}
}
/// type_fields(record) - Get the field names and types of a record
/// Returns: Array of records with name, type, value_preview
///
/// Example:
/// type_fields({name: "ae", version: 3, active: true})
fn bi_type_fields(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let val = if let Some(input) = input {
input
} else if let Some(v) = args.first() {
v.clone()
} else {
return Err(crate::safety::arg_err(
"type_fields: requires a record value",
));
};
match val {
Value::Record(fields) => {
let result: Vec<Value> = fields
.iter()
.map(|(name, value)| {
let mut r = std::collections::BTreeMap::new();
r.insert("name".to_string(), Value::Str(name.clone()));
r.insert("type".to_string(), Value::Str(detailed_type_name(value)));
// Brief preview of value
let preview = match value {
Value::Str(s) if s.chars().nth(50).is_some() => {
format!("\"{}…\"", s.chars().take(50).collect::<String>())
}
Value::Array(a) => format!("[…] ({} items)", a.len()),
Value::Record(r) => format!("{{…}} ({} fields)", r.len()),
other => format!("{}", other),
};
r.insert("preview".to_string(), Value::Str(preview));
Value::Record(r)
})
.collect();
Ok(Value::Array(result))
}
Value::Table(t) => {
let result: Vec<Value> = t
.schema
.iter()
.map(|h| {
let mut r = std::collections::BTreeMap::new();
r.insert("name".to_string(), Value::Str(h.clone()));
r.insert("type".to_string(), Value::Str("Any".to_string()));
r.insert("preview".to_string(), Value::Str("(column)".to_string()));
Value::Record(r)
})
.collect();
Ok(Value::Array(result))
}
_ => Err(crate::safety::bad_arg(
"type_fields",
"a Record or Table",
&detailed_type_name(&val),
)),
}
}
/// type_schema(value) - Generate a JSON Schema for a value's structure
/// Returns: Record representing a JSON Schema
///
/// Example:
/// type_schema({name: "ae", version: 3})
/// # => {type: "object", properties: {name: {type: "string"}, version: {type: "integer"}}}
fn bi_type_schema(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let val = if let Some(input) = input {
input
} else if let Some(v) = args.first() {
v.clone()
} else {
return Err(crate::safety::arg_err("type_schema: requires a value"));
};
let schema = value_to_json_schema(&val);
Ok(Value::from_json(&schema))
}
fn value_to_json_schema(val: &Value) -> serde_json::Value {
use serde_json::json;
match val {
Value::Int(_) => json!({"type": "integer"}),
Value::Float(_) => json!({"type": "number"}),
Value::Str(_) => json!({"type": "string"}),
Value::Bool(_) => json!({"type": "boolean"}),
Value::Null => json!({"type": "null"}),
Value::Uri(_) => json!({"type": "string", "format": "uri"}),
Value::Error(_) => json!({"type": "string", "description": "error"}),
Value::Builtin(_) => json!({"type": "string", "description": "builtin reference"}),
Value::AsyncLambda(_) => json!({"type": "function", "async": true}),
Value::Future(_) => json!({"type": "object", "description": "future"}),
Value::Array(items) => {
if items.is_empty() {
json!({"type": "array", "items": {}})
} else {
let item_schema = value_to_json_schema(&items[0]);
json!({"type": "array", "items": item_schema})
}
}
Value::Record(fields) => {
let mut properties = serde_json::Map::new();
let required: Vec<String> = fields.keys().cloned().collect();
for (k, v) in fields {
properties.insert(k.clone(), value_to_json_schema(v));
}
json!({
"type": "object",
"properties": serde_json::Value::Object(properties),
"required": required,
})
}
Value::Lambda(l) => {
json!({"type": "function", "parameters": l.params.len()})
}
Value::Table(t) => {
let mut properties = serde_json::Map::new();
for h in &t.schema {
properties.insert(h.clone(), json!({}));
}
json!({
"type": "array",
"items": {
"type": "object",
"properties": serde_json::Value::Object(properties),
}
})
}
}
}
/// is_type(value, type_name) - Check if a value matches a type name
/// Returns: Bool
///
/// Example:
/// is_type(42, "Int") # => true
/// is_type("hi", "String") # => true
/// is_type([], "Array") # => true
fn bi_is_type(args: Vec<Value>) -> Result<Value> {
if args.len() < 2 {
return Err(crate::safety::arg_err(
"is_type: requires (value, type_name) arguments",
));
}
let type_name = match &args[1] {
Value::Str(s) => s.to_lowercase(),
other => {
return Err(crate::safety::bad_arg(
"is_type",
"type_name: String",
other.type_name(),
))
}
};
let matches = match (&args[0], type_name.as_str()) {
(Value::Int(_), "int" | "integer" | "number") => true,
(Value::Float(_), "float" | "number" | "double") => true,
(Value::Str(_), "string" | "str" | "text") => true,
(Value::Bool(_), "bool" | "boolean") => true,
(Value::Array(_), "array" | "list") => true,
(Value::Record(_), "record" | "object" | "map") => true,
(Value::Uri(_), "uri" | "url") => true,
(Value::Lambda(_), "lambda" | "function" | "fn") => true,
(Value::AsyncLambda(_), "asynclambda" | "async_lambda" | "function" | "fn") => true,
(Value::Null, "null" | "nil" | "none") => true,
(Value::Table(_), "table") => true,
(Value::Error(_), "error") => true,
(Value::Future(_), "future") => true,
(Value::Builtin(_), "builtin") => true,
(_, "any") => true,
_ => false,
};
Ok(Value::Bool(matches))
}
// ========== Ontology Export Functions ==========
/// ontology_json() - Export the OS ontology as JSON Schema
/// Returns: Record with the full JSON Schema representation
fn bi_ontology_json() -> Result<Value> {
let schema = crate::providers::ontology::OS_ONTOLOGY.to_json_schema();
Ok(Value::from_json(&schema))
}
/// ontology_jsonld() - Export the OS ontology as JSON-LD (Linked Data)
/// Returns: Record with JSON-LD representation for semantic web
fn bi_ontology_jsonld() -> Result<Value> {
let ld = crate::providers::ontology::OS_ONTOLOGY.to_json_ld();
Ok(Value::from_json(&ld))
}
/// ontology_owl() - Export the OS ontology as OWL/RDF Turtle format
/// Returns: String with OWL Turtle serialization
fn bi_ontology_owl() -> Result<Value> {
let turtle = crate::providers::ontology::OS_ONTOLOGY.to_owl_turtle();
Ok(Value::Str(turtle))
}
/// ontology_shacl() - Export the OS ontology as SHACL shapes for validation
/// Returns: Record with SHACL shapes graph
fn bi_ontology_shacl() -> Result<Value> {
let shacl = crate::providers::ontology::OS_ONTOLOGY.to_shacl();
Ok(Value::from_json(&shacl))
}
// ========== MCP Server Detection Functions ==========
/// mcp_servers() - Detect all available MCP servers
/// Returns: Array of records with MCP server information
///
/// Each record contains:
/// - name: String - Server name (e.g., "filesystem", "git")
/// - endpoint: String - Server endpoint URL
/// - available: Bool - Whether server is reachable
/// - tools: Array[String] - List of available tool names
///
/// Example:
/// let servers = mcp_servers()
/// servers | foreach(fn(s) => print(s.name + ": " + len(s.tools) + " tools"))
fn mcp_server_record(server: &crate::ai::McpServerInfo) -> Value {
let mut record = BTreeMap::new();
record.insert("name".to_string(), Value::Str(server.name.clone()));
record.insert("endpoint".to_string(), Value::Str(server.endpoint.clone()));
record.insert("available".to_string(), Value::Bool(server.available));
let tools: Vec<Value> = server.tools.iter().map(|t| Value::Str(t.clone())).collect();
record.insert("tools".to_string(), Value::Array(tools));
Value::Record(record)
}
fn bi_mcp_servers(_args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("mcp_servers", &input)?;
let servers = crate::ai::detect_mcp_servers();
Ok(Value::Array(
servers.iter().map(mcp_server_record).collect(),
))
}
/// mcp_detect(endpoint?) - Detect MCP server at specific endpoint or find first available
/// Args:
/// - endpoint (optional): String - Specific endpoint to check
/// Returns: Record with MCP server info, or null when no detected server matches
///
/// Null is the answer for "nothing is listening" (`examples/22_tui_mcp.ish`
/// relies on it); a found server is a full record.
///
/// Example:
/// let fs_server = mcp_detect("http://localhost:3001")
/// let any_server = mcp_detect()
fn bi_mcp_detect(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("mcp_detect", &input)?;
let servers = crate::ai::detect_mcp_servers();
match args.first() {
Some(v) => {
let endpoint = expect_string("mcp_detect", v)?;
Ok(servers
.iter()
.find(|s| s.endpoint == endpoint)
.map(mcp_server_record)
.unwrap_or(Value::Null))
}
None => Ok(servers
.first()
.map(mcp_server_record)
.unwrap_or(Value::Null)),
}
}
/// mcp_cache_clear() - Clear the MCP detection cache
/// Returns: Bool - true if cache was cleared successfully
///
/// Example:
/// mcp_cache_clear()
/// let servers = mcp_servers() # This will re-detect servers
fn bi_mcp_cache_clear(_args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("mcp_cache_clear", &input)?;
// A failed clear used to be `let _ =`, and `true` came back regardless.
crate::ai::clear_mcp_cache().map_err(|e| {
crate::safety::bad_state(
"mcp_cache_clear",
&format!("the MCP cache could not be cleared: {e}"),
"a new ish process",
)
})?;
Ok(Value::Bool(true))
}
/// mcp_cache_status() - Get MCP cache status information
/// Returns: Record with cache status details
///
/// Example:
/// let status = mcp_cache_status()
/// print("Cache TTL: " + status.ttl_seconds)
fn bi_mcp_cache_status(_args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("mcp_cache_status", &input)?;
let mut record = BTreeMap::new();
// The cache is always on, and its TTL is the one `detect_mcp_servers` uses.
record.insert("enabled".to_string(), Value::Bool(true));
record.insert("ttl_seconds".to_string(), Value::Int(30));
// A poisoned lock used to read as "no cached data": a failure to look was
// reported as an empty cache.
let has_cached_data = match crate::ai::MCP_CACHE.lock() {
Ok(cache_guard) => cache_guard.is_some(),
Err(_) => {
return Err(crate::safety::bad_state(
"mcp_cache_status",
"the MCP cache lock is poisoned by an earlier panic",
"a new ish process",
))
}
};
record.insert("has_cached_data".to_string(), Value::Bool(has_cached_data));
Ok(Value::Record(record))
}
// ==================== MCP Server/Client Functions ====================
/// Helper function to map category strings to ToolCategory enum
fn parse_tool_category(s: &str) -> Option<crate::os_tools::ToolCategory> {
use crate::os_tools::ToolCategory;
match s.to_lowercase().as_str() {
"filesystem" | "file_system" => Some(ToolCategory::FileSystem),
"textprocessing" | "text_processing" | "text" => Some(ToolCategory::TextProcessing),
"networktools" | "network_tools" | "network" => Some(ToolCategory::NetworkTools),
"systeminfo" | "system_info" | "system" => Some(ToolCategory::SystemInfo),
"processmanagement" | "process_management" | "process" => {
Some(ToolCategory::ProcessManagement)
}
"archives" | "archive" => Some(ToolCategory::Archives),
"searchtools" | "search_tools" | "search" => Some(ToolCategory::SearchTools),
"monitoring" => Some(ToolCategory::Monitoring),
"development" | "dev" => Some(ToolCategory::Development),
"media" => Some(ToolCategory::Media),
"security" => Some(ToolCategory::Security),
"utilities" | "util" => Some(ToolCategory::Utilities),
"webtools" | "web_tools" | "web" => Some(ToolCategory::WebTools),
"cybersecurity" | "cyber_security" | "cyber" => Some(ToolCategory::CyberSecurity),
"reconnaissance" | "recon" => Some(ToolCategory::Reconnaissance),
"forensics" => Some(ToolCategory::Forensics),
"cryptography" | "crypto" => Some(ToolCategory::Cryptography),
"cloudaws" | "cloud_aws" | "aws" => Some(ToolCategory::CloudAWS),
"cloudazure" | "cloud_azure" | "azure" => Some(ToolCategory::CloudAzure),
"cloudgcp" | "cloud_gcp" | "gcp" => Some(ToolCategory::CloudGCP),
"kubernetes" | "k8s" => Some(ToolCategory::Kubernetes),
"containers" | "docker" | "podman" => Some(ToolCategory::Containers),
"infrastructure" | "terraform" | "ansible" => Some(ToolCategory::Infrastructure),
"database" | "db" => Some(ToolCategory::Database),
"dataprocessing" | "data_processing" | "data" => Some(ToolCategory::DataProcessing),
"machinelearning" | "machine_learning" | "ml" => Some(ToolCategory::MachineLearning),
_ => None,
}
}
/// mcp_server(config?) - Create a local MCP server instance for AI tool access
/// Args:
/// - config (optional): Record with configuration options
/// - max_safety_level: String - "safe", "caution", "dangerous", "critical" (default: "caution")
/// - allow_admin: Bool - Allow admin tools (default: false)
/// - categories: Array[String] - Limit to specific tool categories (default: all)
/// - blocked_tools: Array[String] - Tools to block (default: [])
/// Returns: Record with MCP server information
///
/// Example:
/// let server = mcp_server()
/// let safe_server = mcp_server({ max_safety_level: "safe" })
/// let k8s_server = mcp_server({ categories: ["kubernetes", "containers"] })
fn bi_mcp_server(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
use crate::mcp::{McpConfig, McpServer};
use crate::os_tools::SafetyLevel;
// Parse config from args
let mut config = McpConfig::default();
let mut allowed_categories = None;
if !args.is_empty() {
if let Value::Record(rec) = &args[0] {
// Parse safety level
if let Some(Value::Str(level)) = rec.get("max_safety_level") {
config.max_safety_level = match level.to_lowercase().as_str() {
"safe" => SafetyLevel::Safe,
"caution" => SafetyLevel::Caution,
"dangerous" => SafetyLevel::Dangerous,
"critical" => SafetyLevel::Critical,
// An unknown level ("dangerus") silently became Caution.
other => {
return Err(crate::safety::bad_arg(
"mcp_server",
"max_safety_level safe, caution, dangerous or critical",
other,
))
}
};
}
// Parse allow_admin
if let Some(Value::Bool(b)) = rec.get("allow_admin") {
config.allow_admin_tools = *b;
}
// Parse blocked_tools
if let Some(Value::Array(arr)) = rec.get("blocked_tools") {
config.blocked_tools = arr
.iter()
.filter_map(|v| {
if let Value::Str(s) = v {
Some(s.clone())
} else {
None
}
})
.collect();
}
// Parse categories
if let Some(Value::Array(arr)) = rec.get("categories") {
let cats: Vec<_> = arr
.iter()
.filter_map(|v| {
if let Value::Str(s) = v {
parse_tool_category(s)
} else {
None
}
})
.collect();
if !cats.is_empty() {
allowed_categories = Some(cats);
}
}
}
}
// Set allowed_categories in config
config.allowed_categories = allowed_categories;
// Create MCP server
let server = McpServer::with_config(config);
let init_result = server.initialize();
// Return server info as record
let mut record = BTreeMap::new();
record.insert(
"protocol_version".to_string(),
Value::Str(init_result.protocol_version),
);
record.insert(
"server_name".to_string(),
Value::Str(init_result.server_info.name),
);
record.insert(
"server_version".to_string(),
Value::Str(init_result.server_info.version),
);
record.insert(
"tool_count".to_string(),
Value::Int(server.list_tools().len() as i64),
);
let capabilities: Vec<Value> = vec![
Value::Str("tools".to_string()),
Value::Str("resources".to_string()),
Value::Str("prompts".to_string()),
];
record.insert("capabilities".to_string(), Value::Array(capabilities));
Ok(Value::Record(record))
}
/// mcp_tools(config?) - List available MCP tools with their schemas
/// Args:
/// - config (optional): Record with filter options
/// - category: String - Filter by category
/// - search: String - Search by name/description
/// - safety_level: String - Max safety level
/// Returns: Array of tool records with name, description, and input schema
///
/// Example:
/// let all_tools = mcp_tools()
/// let k8s_tools = mcp_tools({ category: "kubernetes" })
/// let git_tools = mcp_tools({ search: "git" })
fn bi_mcp_tools(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
use crate::mcp::{McpConfig, McpServer};
use crate::os_tools::SafetyLevel;
refuse_piped("mcp_tools", &input)?;
// Parse filters. A filter that is not understood is refused: an unknown
// category used to list every tool, and an unknown safety level used to
// mean Caution, both without a word.
let mut config = McpConfig::default();
let mut search_filter: Option<String> = None;
if let Some(first) = args.first() {
let Value::Record(rec) = first else {
return Err(crate::safety::bad_arg(
"mcp_tools",
"config: Record",
first.type_name(),
));
};
for key in rec.keys() {
if !matches!(key.as_str(), "category" | "search" | "safety_level") {
return Err(crate::safety::bad_arg(
"mcp_tools",
"config keys category, search, safety_level",
key,
));
}
}
// Parse category filter
if let Some(v) = rec.get("category") {
let cat_str = expect_string("mcp_tools", v)?;
let cat = parse_tool_category(cat_str)
.ok_or_else(|| crate::safety::bad_arg("mcp_tools", "a known category", cat_str))?;
config.allowed_categories = Some(vec![cat]);
}
// Parse search filter
if let Some(v) = rec.get("search") {
search_filter = Some(expect_string("mcp_tools", v)?.to_string());
}
// Parse safety level
if let Some(v) = rec.get("safety_level") {
let level = expect_string("mcp_tools", v)?;
config.max_safety_level = match level.to_lowercase().as_str() {
"safe" => SafetyLevel::Safe,
"caution" => SafetyLevel::Caution,
"dangerous" => SafetyLevel::Dangerous,
"critical" => SafetyLevel::Critical,
_ => {
return Err(crate::safety::bad_arg(
"mcp_tools",
"safety_level safe, caution, dangerous or critical",
level,
))
}
};
}
}
let server = McpServer::with_config(config);
let tools = server.list_tools();
// Apply search filter
let filtered_tools: Vec<_> = if let Some(ref search) = search_filter {
let search_lower = search.to_lowercase();
tools
.into_iter()
.filter(|t| {
t.name.to_lowercase().contains(&search_lower)
|| t.description.to_lowercase().contains(&search_lower)
})
.collect()
} else {
tools
};
let tool_records: Vec<Value> = filtered_tools
.into_iter()
.map(|tool| {
let mut rec = BTreeMap::new();
rec.insert("name".to_string(), Value::Str(tool.name));
rec.insert("description".to_string(), Value::Str(tool.description));
rec.insert(
"input_schema".to_string(),
Value::Str(tool.input_schema.to_string()),
);
Value::Record(rec)
})
.collect();
Ok(Value::Array(tool_records))
}
/// mcp_call(name, args?) - Call an MCP tool by name with arguments
/// Args:
/// - name: String - Tool name to call
/// - args (optional): Record - Arguments for the tool
/// Returns: Record with result content or error
///
/// Example:
/// let result = mcp_call("ls", { path: "." })
/// let files = mcp_call("find", { directory: "/home", pattern: "*.rs" })
fn bi_mcp_call(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
use crate::mcp::{McpContent, McpServer, McpToolCall};
use std::collections::HashMap;
refuse_piped("mcp_call", &input)?;
let Some(first) = args.first() else {
return Err(crate::safety::bad_arg(
"mcp_call",
"a tool name as the first argument",
"nothing",
));
};
let tool_name = expect_string("mcp_call", first)?;
// Build arguments HashMap. A non-record second argument used to be dropped
// and the tool run without its arguments.
let tool_args: HashMap<String, serde_json::Value> = match args.get(1) {
None => HashMap::new(),
Some(Value::Record(rec)) => rec
.iter()
.map(|(k, v)| (k.clone(), value_to_json_owned(v.clone())))
.collect(),
Some(other) => {
return Err(crate::safety::bad_arg(
"mcp_call",
"args: Record",
other.type_name(),
))
}
};
let server = McpServer::new();
// An unknown tool used to come back as a record with is_error set.
if server.get_tool(tool_name).is_none() {
if server.tools_db().get_tool(tool_name).is_some() {
return Err(crate::safety::policy_deny(
"mcp_call",
format!("tool `{tool_name}` is not available under the default MCP policy"),
"the tool is blocked or above the default safety level",
));
}
return Err(crate::safety::not_found("mcp_call", "tool", tool_name));
}
let call = McpToolCall {
name: tool_name.to_string(),
arguments: tool_args,
};
let result = server.call_tool(call);
// A tool that failed is a coded tool failure, not a record the caller has
// to remember to check.
if result.is_error == Some(true) {
let text: Vec<&str> = result
.content
.iter()
.filter_map(|c| match c {
McpContent::Text { text } => Some(text.as_str()),
_ => None,
})
.collect();
return Err(mcp_tool_failed(tool_name, &text.join("\n")));
}
let mut record = BTreeMap::new();
record.insert(
"is_error".to_string(),
Value::Bool(result.is_error.unwrap_or(false)),
);
let content_values: Vec<Value> = result
.content
.into_iter()
.map(|c| {
let mut content_rec = BTreeMap::new();
match c {
McpContent::Text { text } => {
content_rec.insert("type".to_string(), Value::Str("text".to_string()));
content_rec.insert("text".to_string(), Value::Str(text));
}
McpContent::Image { data, mime_type } => {
content_rec.insert("type".to_string(), Value::Str("image".to_string()));
content_rec.insert("data".to_string(), Value::Str(data));
content_rec.insert("mime_type".to_string(), Value::Str(mime_type));
}
McpContent::Resource { uri, mime_type } => {
content_rec.insert("type".to_string(), Value::Str("resource".to_string()));
content_rec.insert("uri".to_string(), Value::Str(uri));
if let Some(mt) = mime_type {
content_rec.insert("mime_type".to_string(), Value::Str(mt));
}
}
}
Value::Record(content_rec)
})
.collect();
record.insert("content".to_string(), Value::Array(content_values));
Ok(Value::Record(record))
}
/// mcp_resources(type?) - List available MCP resources
/// Args:
/// - type (optional): String - Filter by resource type ("text", "blob", etc.)
/// Returns: Array of resource records
///
/// Example:
/// let resources = mcp_resources()
/// let text_resources = mcp_resources("text")
fn bi_mcp_resources(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
use crate::mcp::McpServer;
let type_filter = if !args.is_empty() {
if let Value::Str(s) = &args[0] {
Some(s.clone())
} else {
None
}
} else {
None
};
let server = McpServer::new();
let resources = server.list_resources();
let filtered: Vec<_> = if let Some(ref t) = type_filter {
resources
.into_iter()
.filter(|r| r.mime_type.as_ref().map(|m| m.contains(t)).unwrap_or(false))
.collect()
} else {
resources
};
let resource_records: Vec<Value> = filtered
.into_iter()
.map(|res| {
let mut rec = BTreeMap::new();
rec.insert("uri".to_string(), Value::Str(res.uri));
rec.insert("name".to_string(), Value::Str(res.name));
if let Some(desc) = res.description {
rec.insert("description".to_string(), Value::Str(desc));
}
if let Some(mime) = res.mime_type {
rec.insert("mime_type".to_string(), Value::Str(mime));
}
Value::Record(rec)
})
.collect();
Ok(Value::Array(resource_records))
}
/// mcp_prompts(name?) - List available MCP prompts or get specific prompt
/// Args:
/// - name (optional): String - Get specific prompt by name
/// Returns: Array of prompt records or single prompt
///
/// Example:
/// let prompts = mcp_prompts()
/// let find_prompt = mcp_prompts("find-tool")
fn bi_mcp_prompts(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
use crate::mcp::McpServer;
let server = McpServer::new();
let prompts = server.list_prompts();
if !args.is_empty() {
if let Value::Str(name) = &args[0] {
if let Some(prompt) = prompts.into_iter().find(|p| &p.name == name) {
let mut rec = BTreeMap::new();
rec.insert("name".to_string(), Value::Str(prompt.name));
if let Some(desc) = prompt.description {
rec.insert("description".to_string(), Value::Str(desc));
}
if let Some(arguments) = prompt.arguments {
let args_values: Vec<Value> = arguments
.into_iter()
.map(|arg| {
let mut arg_rec = BTreeMap::new();
arg_rec.insert("name".to_string(), Value::Str(arg.name));
if let Some(desc) = arg.description {
arg_rec.insert("description".to_string(), Value::Str(desc));
}
arg_rec.insert(
"required".to_string(),
Value::Bool(arg.required.unwrap_or(false)),
);
Value::Record(arg_rec)
})
.collect();
rec.insert("arguments".to_string(), Value::Array(args_values));
}
return Ok(Value::Record(rec));
}
// An unknown prompt name answered null.
return Err(crate::safety::not_found("mcp_prompts", "prompt", name));
}
}
let prompt_records: Vec<Value> = prompts
.into_iter()
.map(|prompt| {
let mut rec = BTreeMap::new();
rec.insert("name".to_string(), Value::Str(prompt.name));
if let Some(desc) = prompt.description {
rec.insert("description".to_string(), Value::Str(desc));
}
if let Some(arguments) = prompt.arguments {
let args_values: Vec<Value> = arguments
.into_iter()
.map(|arg| {
let mut arg_rec = BTreeMap::new();
arg_rec.insert("name".to_string(), Value::Str(arg.name));
if let Some(desc) = arg.description {
arg_rec.insert("description".to_string(), Value::Str(desc));
}
arg_rec.insert(
"required".to_string(),
Value::Bool(arg.required.unwrap_or(false)),
);
Value::Record(arg_rec)
})
.collect();
rec.insert("arguments".to_string(), Value::Array(args_values));
}
Value::Record(rec)
})
.collect();
Ok(Value::Array(prompt_records))
}
/// mcp_connect(endpoint) - Connect to an external MCP server
/// Args:
/// - endpoint: String - Server endpoint URL (e.g., "http://localhost:3001")
/// Returns: Record with connection info and available tools
///
/// Example:
/// let client = mcp_connect("http://localhost:3001")
/// let tools = client.tools
fn bi_mcp_connect(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("mcp_connect", &input)?;
if args.is_empty() {
return Err(crate::safety::bad_arg(
"mcp_connect",
"endpoint: String",
"nothing",
));
}
// Only the endpoints `detect_mcp_servers` probes on loopback are ever
// matched. Nothing here opens a connection to an address the caller gave.
let endpoint = expect_string("mcp_connect", &args[0])?;
// Try to detect MCP server at endpoint
let servers = crate::ai::detect_mcp_servers();
if let Some(server) = servers.iter().find(|s| s.endpoint == endpoint) {
let mut record = BTreeMap::new();
record.insert("endpoint".to_string(), Value::Str(server.endpoint.clone()));
record.insert("name".to_string(), Value::Str(server.name.clone()));
record.insert("available".to_string(), Value::Bool(server.available));
let tools: Vec<Value> = server.tools.iter().map(|t| Value::Str(t.clone())).collect();
record.insert("tools".to_string(), Value::Array(tools));
return Ok(Value::Record(record));
}
// Server not detected, return basic info indicating unavailable
let mut record = BTreeMap::new();
record.insert("endpoint".to_string(), Value::Str(endpoint.to_string()));
record.insert("name".to_string(), Value::Str("unknown".to_string()));
record.insert("available".to_string(), Value::Bool(false));
record.insert("tools".to_string(), Value::Array(vec![]));
record.insert(
"error".to_string(),
Value::Str("Server not responding or not detected".to_string()),
);
Ok(Value::Record(record))
}
// Helper function to convert owned Value to serde_json::Value
fn value_to_json_owned(v: Value) -> serde_json::Value {
match v {
Value::Null => serde_json::Value::Null,
Value::Bool(b) => serde_json::Value::Bool(b),
Value::Int(i) => serde_json::Value::Number(serde_json::Number::from(i)),
Value::Float(f) => serde_json::Number::from_f64(f)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null),
Value::Str(s) => serde_json::Value::String(s),
Value::Array(arr) => {
serde_json::Value::Array(arr.into_iter().map(value_to_json_owned).collect())
}
Value::Record(rec) => {
let map: serde_json::Map<String, serde_json::Value> = rec
.into_iter()
.map(|(k, v)| (k, value_to_json_owned(v)))
.collect();
serde_json::Value::Object(map)
}
_ => serde_json::Value::Null,
}
}
/// first(n?) - Get first n elements from array input (default: 1)
/// Args:
/// - n (optional): Int - Number of elements to take (default: 1)
/// Returns: Array with first n elements, or Value if n=1
///
/// Example:
/// [1,2,3,4,5] | first() # Returns 1
/// [1,2,3,4,5] | first(3) # Returns [1,2,3]
fn bi_first(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let (arr_val, count_arg) = if let Some(inp) = input {
// Piped: input is array, args[0] is optional count
(inp, args.first())
} else if !args.is_empty() {
// Not piped: args[0] is array, args[1] is optional count
(args[0].clone(), args.get(1))
} else {
return Err(crate::safety::arg_err("first requires array input"));
};
let arr = expect_array("first", &arr_val)?;
let count = if let Some(c_arg) = count_arg {
expect_int("first", c_arg)?
} else {
1
};
if count <= 0 {
return Ok(Value::Array(vec![]));
}
// Fast path for single element (most common case)
if count == 1 {
return if arr.is_empty() {
Ok(Value::Array(vec![]))
} else {
Ok(arr[0].clone())
};
}
// Use iterator for all other cases (proven to be fastest)
let result: Vec<Value> = arr.iter().take(count as usize).cloned().collect();
Ok(Value::Array(result))
}
/// last(n?) - Get last n elements from array input (default: 1)
/// Args:
/// - n (optional): Int - Number of elements to take (default: 1)
/// Returns: Array with last n elements, or Value if n=1
///
/// Example:
/// [1,2,3,4,5] | last() # Returns 5
/// [1,2,3,4,5] | last(3) # Returns [3,4,5]
fn bi_last(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let (arr_val, count_arg) = if let Some(inp) = input {
// Piped: input is array, args[0] is optional count
(inp, args.first())
} else if !args.is_empty() {
// Not piped: args[0] is array, args[1] is optional count
(args[0].clone(), args.get(1))
} else {
return Err(crate::safety::arg_err("last requires array input"));
};
let arr = expect_array("last", &arr_val)?;
let count = if let Some(c_arg) = count_arg {
expect_int("last", c_arg)?
} else {
1
};
if count <= 0 {
return Ok(Value::Array(vec![]));
}
// Fast path for single element (most common case)
if count == 1 {
return if arr.is_empty() {
Ok(Value::Array(vec![]))
} else {
Ok(arr[arr.len() - 1].clone())
};
}
// Use slice for all other cases (proven to be fastest)
let start_idx = if arr.len() > count as usize {
arr.len() - count as usize
} else {
0
};
let result: Vec<Value> = arr[start_idx..].to_vec();
Ok(Value::Array(result))
}
/// any(predicate?) - Check if any element matches predicate (or any truthy if no predicate)
/// Args:
/// - predicate (optional): Lambda - Function to test each element
/// Returns: Bool - true if any element matches
///
/// Example:
/// [1,2,3] | any(fn(x) => x > 2) # Returns true
/// [false, true, false] | any() # Returns true
/// any([1,2,3], fn(x) => x > 2) # Also works as function call
fn bi_any(args: Vec<Value>, input: Option<Value>, env: &mut Env) -> Result<Value> {
// Determine input array and predicate
// If input is provided via pipe, first arg is predicate (optional)
// If input is first arg (array), second arg is predicate (optional)
let (arr, predicate): (Vec<Value>, Option<Value>) = if let Some(inp) = input {
let arr = expect_array("any", &inp)?.to_vec();
let pred = args.first().cloned();
(arr, pred)
} else if !args.is_empty() {
if let Value::Array(a) = &args[0] {
// First arg is array
let pred = args.get(1).cloned();
(a.clone(), pred)
} else if matches!(&args[0], Value::Lambda(_)) {
// First arg is lambda with empty array (edge case)
return Err(crate::safety::arg_err("any requires an array as input"));
} else {
return Err(crate::safety::arg_err(format!(
"any expects array, got {:?}",
args[0]
)));
}
} else {
return Err(crate::safety::arg_err("any requires an array input"));
};
if let Some(pred_val) = predicate {
// Use predicate function
let predicate = need_lambda(&pred_val, "any")?;
for val in arr {
let result = call_lambda(predicate, &[val.clone()], env)?;
if is_truthy(&result) {
return Ok(Value::Bool(true));
}
}
Ok(Value::Bool(false))
} else {
// No predicate - check for any truthy values
for val in arr {
if is_truthy(&val) {
return Ok(Value::Bool(true));
}
}
Ok(Value::Bool(false))
}
}
/// all(predicate?) - Check if all elements match predicate (or all truthy if no predicate)
/// Args:
/// - predicate (optional): Lambda - Function to test each element
/// Returns: Bool - true if all elements match
///
/// Example:
/// [1,2,3] | all(fn(x) => x > 0) # Returns true
/// [true, true, false] | all() # Returns false
/// all([1,2,3], fn(x) => x > 0) # Also works as function call
fn bi_all(args: Vec<Value>, input: Option<Value>, env: &mut Env) -> Result<Value> {
// Determine input array and predicate
// If input is provided via pipe, first arg is predicate (optional)
// If input is first arg (array), second arg is predicate (optional)
let (arr, predicate): (Vec<Value>, Option<Value>) = if let Some(inp) = input {
let arr = expect_array("all", &inp)?.to_vec();
let pred = args.first().cloned();
(arr, pred)
} else if !args.is_empty() {
if let Value::Array(a) = &args[0] {
// First arg is array
let pred = args.get(1).cloned();
(a.clone(), pred)
} else if matches!(&args[0], Value::Lambda(_)) {
// First arg is lambda with empty array (edge case)
return Err(crate::safety::arg_err("all requires an array as input"));
} else {
return Err(crate::safety::arg_err(format!(
"all expects array, got {:?}",
args[0]
)));
}
} else {
return Err(crate::safety::arg_err("all requires an array input"));
};
if let Some(pred_val) = predicate {
// Use predicate function
let predicate = need_lambda(&pred_val, "all")?;
for val in arr {
let result = call_lambda(predicate, &[val.clone()], env)?;
if !is_truthy(&result) {
return Ok(Value::Bool(false));
}
}
Ok(Value::Bool(true))
} else {
// No predicate - check if all values are truthy
for val in arr {
if !is_truthy(&val) {
return Ok(Value::Bool(false));
}
}
Ok(Value::Bool(true))
}
}
/// Helper function to determine truthiness of a Value
fn is_truthy(val: &Value) -> bool {
match val {
Value::Bool(b) => *b,
Value::Int(i) => *i != 0,
Value::Float(f) => *f != 0.0,
Value::Str(s) => !s.is_empty(),
Value::Array(a) => !a.is_empty(),
Value::Record(r) => !r.is_empty(),
Value::Null => false,
Value::Lambda(_) => true, // Functions are truthy
Value::AsyncLambda(_) => true, // Async lambdas are truthy
Value::Future(_) => true, // Futures are truthy
Value::Uri(_) => true, // URIs are truthy if they exist
Value::Table(t) => !t.rows.is_empty(), // Tables are truthy if they have data
Value::Error(_) => false, // Errors are falsy
Value::Builtin(_) => true, // Builtins are truthy
}
}
// ==================== String Functions ====================
/// split(delimiter) - Split string into array by delimiter
/// Args:
/// - delimiter: String - The delimiter to split on
/// Returns: Array - Array of string parts
///
/// Example:
/// "a,b,c" | split(",") # Returns ["a", "b", "c"]
fn bi_split(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let (text_val, delim_arg) = if let Some(inp) = input {
// Piped: input is string, args[0] is delimiter
(
inp,
args.first()
.ok_or_else(|| crate::safety::arg_err("split requires delimiter argument"))?,
)
} else {
// Not piped: args[0] is string, args[1] is delimiter
(
args.first()
.ok_or_else(|| crate::safety::arg_err("split requires string input"))?
.clone(),
args.get(1)
.ok_or_else(|| crate::safety::arg_err("split requires delimiter argument"))?,
)
};
let text = expect_string("split", &text_val)?;
let delimiter = expect_string("split", delim_arg)?;
let parts: Vec<Value> = text
.split(delimiter)
.map(|s| Value::Str(s.to_string()))
.collect();
Ok(Value::Array(parts))
}
/// join(delimiter) - Join array elements into string with delimiter
/// Args:
/// - delimiter: String - The delimiter to join with
/// Returns: String - Joined string
///
/// Example:
/// ["a", "b", "c"] | join(",") # Returns "a,b,c"
fn bi_join(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let (arr_val, delim_arg) = if let Some(inp) = input {
// Piped: input is array, args[0] is delimiter
(
inp,
args.first()
.ok_or_else(|| crate::safety::arg_err("join requires delimiter argument"))?,
)
} else {
// Not piped: args[0] is array, args[1] is delimiter
(
args.first()
.ok_or_else(|| crate::safety::arg_err("join requires array input"))?
.clone(),
args.get(1)
.ok_or_else(|| crate::safety::arg_err("join requires delimiter argument"))?,
)
};
let arr = expect_array("join", &arr_val)?;
let delimiter = expect_string("join", delim_arg)?;
let strings: Result<Vec<String>> = arr
.iter()
.map(|v| match v {
Value::Str(s) => Ok(s.clone()),
Value::Int(i) => Ok(i.to_string()),
Value::Float(f) => Ok(f.to_string()),
Value::Bool(b) => Ok(b.to_string()),
_ => Err(crate::safety::arg_err(
"join requires array of strings or numbers",
)),
})
.collect();
Ok(Value::Str(strings?.join(delimiter)))
}
/// trim() - Remove leading and trailing whitespace from string
/// Returns: String - Trimmed string
///
/// Example:
/// " hello " | trim() # Returns "hello"
fn bi_trim(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let input_val = input
.or_else(|| args.first().cloned())
.ok_or_else(|| crate::safety::arg_err("trim requires input string"))?;
let text = expect_string("trim", &input_val)?;
Ok(Value::Str(text.trim().to_string()))
}
/// upper() - Convert string to uppercase
/// Returns: String - Uppercase string
///
/// Example:
/// "hello" | upper() # Returns "HELLO"
fn bi_upper(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let input_val = input
.or_else(|| args.first().cloned())
.ok_or_else(|| crate::safety::arg_err("upper requires input string"))?;
let text = expect_string("upper", &input_val)?;
Ok(Value::Str(text.to_uppercase()))
}
/// lower() - Convert string to lowercase
/// Returns: String - Lowercase string
///
/// Example:
/// "HELLO" | lower() # Returns "hello"
fn bi_lower(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let input_val = input
.or_else(|| args.first().cloned())
.ok_or_else(|| crate::safety::arg_err("lower requires input string"))?;
let text = expect_string("lower", &input_val)?;
Ok(Value::Str(text.to_lowercase()))
}
/// replace(old, new) - Replace all occurrences of substring
/// Args:
/// - old: String - Substring to replace
/// - new: String - Replacement substring
/// Returns: String - String with replacements
///
/// Example:
/// "hello world" | replace("world", "universe") # Returns "hello universe"
fn bi_replace(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let (input_val, old_arg, new_arg) = if let Some(inp) = input {
// Piped: input is string, args[0] is old, args[1] is new
(
inp,
args.first()
.ok_or_else(|| crate::safety::arg_err("replace requires old substring argument"))?,
args.get(1)
.ok_or_else(|| crate::safety::arg_err("replace requires new substring argument"))?,
)
} else {
// Not piped: args[0] is string, args[1] is old, args[2] is new
(
args.first()
.ok_or_else(|| crate::safety::arg_err("replace requires string input"))?
.clone(),
args.get(1)
.ok_or_else(|| crate::safety::arg_err("replace requires old substring argument"))?,
args.get(2)
.ok_or_else(|| crate::safety::arg_err("replace requires new substring argument"))?,
)
};
let text = expect_string("replace", &input_val)?;
let old = expect_string("replace", old_arg)?;
let new = expect_string("replace", new_arg)?;
Ok(Value::Str(text.replace(old, new)))
}
/// contains(substring) - Check if string contains substring
/// Args:
/// - substring: String - Substring to search for
/// Returns: Bool - true if substring found
///
/// Example:
/// "hello world" | contains("world") # Returns true
fn bi_contains(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let (input_val, substring_arg) = if let Some(inp) = input {
// Piped: input is string, args[0] is substring
(
inp,
args.first()
.ok_or_else(|| crate::safety::arg_err("contains requires substring argument"))?,
)
} else {
// Not piped: args[0] is string, args[1] is substring
(
args.first()
.ok_or_else(|| crate::safety::arg_err("contains requires string input"))?
.clone(),
args.get(1)
.ok_or_else(|| crate::safety::arg_err("contains requires substring argument"))?,
)
};
let text = expect_string("contains", &input_val)?;
let substring = expect_string("contains", substring_arg)?;
Ok(Value::Bool(text.contains(substring)))
}
/// starts_with(prefix) - Check if string starts with prefix
/// Args:
/// - prefix: String - Prefix to check
/// Returns: Bool - true if string starts with prefix
///
/// Example:
/// "hello world" | starts_with("hello") # Returns true
fn bi_starts_with(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let (input_val, prefix_arg) = if let Some(inp) = input {
(
inp,
args.first()
.ok_or_else(|| crate::safety::arg_err("starts_with requires prefix argument"))?,
)
} else {
(
args.first()
.ok_or_else(|| crate::safety::arg_err("starts_with requires string input"))?
.clone(),
args.get(1)
.ok_or_else(|| crate::safety::arg_err("starts_with requires prefix argument"))?,
)
};
let text = expect_string("starts_with", &input_val)?;
let prefix = expect_string("starts_with", prefix_arg)?;
Ok(Value::Bool(text.starts_with(prefix)))
}
/// ends_with(suffix) - Check if string ends with suffix
/// Args:
/// - suffix: String - Suffix to check
/// Returns: Bool - true if string ends with suffix
///
/// Example:
/// "hello world" | ends_with("world") # Returns true
fn bi_ends_with(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let (input_val, suffix_arg) = if let Some(inp) = input {
(
inp,
args.first()
.ok_or_else(|| crate::safety::arg_err("ends_with requires suffix argument"))?,
)
} else {
(
args.first()
.ok_or_else(|| crate::safety::arg_err("ends_with requires string input"))?
.clone(),
args.get(1)
.ok_or_else(|| crate::safety::arg_err("ends_with requires suffix argument"))?,
)
};
let text = expect_string("ends_with", &input_val)?;
let suffix = expect_string("ends_with", suffix_arg)?;
Ok(Value::Bool(text.ends_with(suffix)))
}
// ==================== Array Functions (Extended) ====================
/// flatten() - Flatten nested arrays one level deep
/// Returns: Array - Flattened array
///
/// Example:
/// [[1,2],[3,4],[5]] | flatten() # Returns [1,2,3,4,5]
/// flatten([[1,2],[3,4]]) # Also works as function call
fn bi_flatten(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let input_val = input
.or_else(|| args.first().cloned())
.ok_or_else(|| crate::safety::arg_err("flatten requires input array"))?;
let arr = expect_array("flatten", &input_val)?;
let mut result = Vec::new();
for val in arr {
match val {
Value::Array(inner) => result.extend(inner.clone()),
other => result.push(other.clone()),
}
}
Ok(Value::Array(result))
}
/// reverse() - Reverse array or string
/// Returns: Array or String - Reversed input
///
/// Example:
/// [1,2,3,4,5] | reverse() # Returns [5,4,3,2,1]
fn bi_reverse(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let input_val = input
.or_else(|| args.first().cloned())
.ok_or_else(|| crate::safety::arg_err("reverse requires input"))?;
match input_val {
Value::Array(mut arr) => {
arr.reverse();
Ok(Value::Array(arr))
}
Value::Str(s) => Ok(Value::Str(s.chars().rev().collect())),
_ => Err(crate::safety::arg_err(
"reverse requires array or string input",
)),
}
}
/// slice(start, end?) - Extract slice of array or string
/// Args:
/// - start: Int - Start index (inclusive)
/// - end: Int (optional) - End index (exclusive)
/// Returns: Array or String - Sliced input
///
/// Example:
/// [1,2,3,4,5] | slice(1, 3) # Returns [2,3]
/// slice([1,2,3,4,5], 1, 3) # Also works as function call
fn bi_slice(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
// Determine input and index arguments
// If input is provided via pipe, args are (start, end?)
// If input is first arg, args are (array, start, end?)
let (input_val, start_idx, end_arg) = if let Some(inp) = input {
// Piped input: args are start, end?
if args.is_empty() {
return Err(crate::safety::arg_err(
"slice requires at least start index",
));
}
let start = expect_int("slice", &args[0])? as usize;
let end = args.get(1);
(inp, start, end)
} else {
// Function call: first arg is array, then start, end?
if args.len() < 2 {
return Err(crate::safety::arg_err(
"slice(array, start, end?) requires at least array and start",
));
}
let arr = args[0].clone();
let start = expect_int("slice", &args[1])? as usize;
let end = args.get(2);
(arr, start, end)
};
match input_val {
Value::Array(arr) => {
let end = if let Some(e) = end_arg {
expect_int("slice", e)? as usize
} else {
arr.len()
};
let end = end.min(arr.len());
let start = start_idx.min(end);
Ok(Value::Array(arr[start..end].to_vec()))
}
Value::Str(s) => {
let chars: Vec<char> = s.chars().collect();
let end = if let Some(e) = end_arg {
expect_int("slice", e)? as usize
} else {
chars.len()
};
let end = end.min(chars.len());
let start = start_idx.min(end);
Ok(Value::Str(chars[start..end].iter().collect()))
}
_ => Err(crate::safety::arg_err(
"slice requires array or string input",
)),
}
}
/// Most elements `range` will build. A longer range is refused before any
/// element is allocated, as a coded `BudgetExceeded` error, so
/// `range(0, 1000000000000)` cannot exhaust memory.
const RANGE_MAX: i128 = 10_000_000;
/// range(start, end?, step?) - Generate array of integers
/// Args:
/// - start: Int - Start value (or end if only one arg)
/// - end: Int (optional) - End value (exclusive)
/// - step: Int (optional) - Step size (default 1)
/// Returns: Array - Array of integers
///
/// Example:
/// range(5) # Returns [0,1,2,3,4]
/// range(2, 7) # Returns [2,3,4,5,6]
/// range(0, 10, 2) # Returns [0,2,4,6,8]
fn bi_range(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
if args.is_empty() {
return Err(crate::safety::arg_err(
"range requires at least one argument",
));
}
let (start, end, step) = if args.len() == 1 {
// range(n) -> 0..n
(0, expect_int("range", &args[0])?, 1)
} else if args.len() == 2 {
// range(start, end) -> start..end
(
expect_int("range", &args[0])?,
expect_int("range", &args[1])?,
1,
)
} else {
// range(start, end, step)
(
expect_int("range", &args[0])?,
expect_int("range", &args[1])?,
expect_int("range", &args[2])?,
)
};
if step == 0 {
return Err(crate::safety::arg_err("range step cannot be zero"));
}
// Count first, in i128, so no step or offset can overflow i64. The count
// is refused before anything is allocated.
let (s, e, st) = (start as i128, end as i128, step as i128);
let count: i128 = if st > 0 {
if s < e {
(e - s + st - 1) / st
} else {
0
}
} else if s > e {
(s - e - st - 1) / -st
} else {
0
};
if count > RANGE_MAX {
return Err(crate::safety::budget_exceeded(
"range",
format!("{count} elements exceed the {RANGE_MAX} element limit"),
"iterate in chunks, or use a smaller range",
));
}
let mut result = Vec::with_capacity(count as usize);
for i in 0..count {
// Each element lies between start and end, so it fits in i64.
result.push(Value::Int((s + i * st) as i64));
}
Ok(Value::Array(result))
}
/// zip(array2) - Zip two arrays into array of pairs
/// Args:
/// - array2: Array - Second array to zip with
/// Returns: Array - Array of [item1, item2] pairs
///
/// Example:
/// [1,2,3] | zip(["a","b","c"]) # Returns [[1,"a"],[2,"b"],[3,"c"]]
fn bi_zip(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let (arr1_val, arr2_val) = if let Some(inp) = input {
// Piped: input is first array, args[0] is second array
(
inp,
args.first()
.ok_or_else(|| crate::safety::arg_err("zip requires second array argument"))?
.clone(),
)
} else {
// Not piped: args[0] is first array, args[1] is second array
(
args.first()
.ok_or_else(|| crate::safety::arg_err("zip requires two array arguments"))?
.clone(),
args.get(1)
.ok_or_else(|| crate::safety::arg_err("zip requires two array arguments"))?
.clone(),
)
};
let arr1 = expect_array("zip", &arr1_val)?;
let arr2 = expect_array("zip", &arr2_val)?;
let mut result = Vec::new();
for (a, b) in arr1.iter().zip(arr2.iter()) {
result.push(Value::Array(vec![a.clone(), b.clone()]));
}
Ok(Value::Array(result))
}
/// push(item) - Add item to end of array
/// Args:
/// - item: Any - Item to add
/// Returns: Array - Array with item added
///
/// Example:
/// [1,2,3] | push(4) # Returns [1,2,3,4]
fn bi_push(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let (arr_val, item) = if let Some(inp) = input {
// Piped: input is array, args[0] is item
(
inp,
args.first()
.ok_or_else(|| crate::safety::arg_err("push requires item argument"))?,
)
} else {
// Not piped: args[0] is array, args[1] is item
(
args.first()
.ok_or_else(|| crate::safety::arg_err("push requires array input"))?
.clone(),
args.get(1)
.ok_or_else(|| crate::safety::arg_err("push requires item argument"))?,
)
};
let mut arr = expect_array("push", &arr_val)?.to_vec();
arr.push(item.clone());
Ok(Value::Array(arr))
}
/// concat(array2) - Concatenate two arrays
/// Args:
/// - array2: Array - Array to concatenate
/// Returns: Array - Combined array
///
/// Example:
/// [1,2,3] | concat([4,5,6]) # Returns [1,2,3,4,5,6]
fn bi_concat(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let (arr1_val, arr2_val) = if let Some(inp) = input {
// Piped: input is first array, args[0] is second array
(
inp,
args.first()
.ok_or_else(|| crate::safety::arg_err("concat requires second array argument"))?
.clone(),
)
} else {
// Not piped: args[0] is first array, args[1] is second array
(
args.first()
.ok_or_else(|| crate::safety::arg_err("concat requires two array arguments"))?
.clone(),
args.get(1)
.ok_or_else(|| crate::safety::arg_err("concat requires two array arguments"))?
.clone(),
)
};
let mut arr1 = expect_array("concat", &arr1_val)?.to_vec();
let arr2 = expect_array("concat", &arr2_val)?;
arr1.extend(arr2.iter().cloned());
Ok(Value::Array(arr1))
}
// ==================== Math Functions ====================
/// abs(number?) - Get absolute value
/// Args:
/// - number: Number (optional if piped)
/// Returns: Number - Absolute value
///
/// Example:
/// abs(-5) # Returns 5
fn bi_abs(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let val = if args.is_empty() {
input.ok_or_else(|| crate::safety::arg_err("abs requires number argument"))?
} else {
args[0].clone()
};
match val {
Value::Int(n) => Ok(Value::Int(n.abs())),
Value::Float(f) => Ok(Value::Float(f.abs())),
_ => Err(crate::safety::arg_err("abs requires numeric input")),
}
}
/// min(a, b) - Get minimum of two numbers
/// Args:
/// - a: Number
/// - b: Number
/// Returns: Number - Minimum value
///
/// Example:
/// min(3, 7) # Returns 3
/// The array `min`/`max` should aggregate over, if this is the aggregate form.
///
/// Both builtins were binary-only: `min(a, b)`. The ontology advertised them
/// under `Aggregation` as "Get minimum value" / "Get maximum value" with an
/// empty parameter list, so an agent reading the ontology writes `max(xs)` or
/// `xs | max` and is told "max requires two arguments" -- a discovery surface
/// describing a function that does not exist. The aggregate form is accepted
/// here; the two-number form below is untouched.
fn aggregate_operand<'a>(args: &'a [Value], input: &'a Option<Value>) -> Option<&'a Vec<Value>> {
match (args.first(), args.len()) {
(Some(Value::Array(items)), 1) => Some(items),
(None, _) => match input {
Some(Value::Array(items)) => Some(items),
_ => None,
},
_ => None,
}
}
/// Largest (`want_max`) or smallest element of a numeric array.
fn fold_extremum(items: &[Value], want_max: bool) -> Result<Value> {
if items.is_empty() {
return Err(crate::safety::arg_err(if want_max {
"max of an empty array is undefined"
} else {
"min of an empty array is undefined"
}));
}
// Stay in Int while every element is one, so `[1, 5, 3] | max` is `5` and
// not `5.0`: an agent comparing the answer to a literal should not have to
// know whether the aggregate silently promoted.
let all_int = items.iter().all(|v| matches!(v, Value::Int(_)));
let mut best: Option<f64> = None;
for v in items {
let n = match v {
Value::Int(i) => *i as f64,
Value::Float(f) => *f,
other => {
return Err(crate::safety::arg_err(&format!(
"{} requires numeric elements, found {}",
if want_max { "max" } else { "min" },
other.type_name()
)))
}
};
best = Some(match best {
None => n,
Some(b) if want_max => b.max(n),
Some(b) => b.min(n),
});
}
let b = best.expect("non-empty array yields a value");
Ok(if all_int {
Value::Int(b as i64)
} else {
Value::Float(b)
})
}
fn bi_min(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
if let Some(items) = aggregate_operand(&args, &input) {
return fold_extremum(items, false);
}
if args.len() < 2 {
return Err(crate::safety::arg_err(
"min requires two numbers, one array, or a piped array",
));
}
match (&args[0], &args[1]) {
(Value::Int(a), Value::Int(b)) => Ok(Value::Int(*a.min(b))),
(Value::Float(a), Value::Float(b)) => Ok(Value::Float(a.min(*b))),
(Value::Int(a), Value::Float(b)) => Ok(Value::Float((*a as f64).min(*b))),
(Value::Float(a), Value::Int(b)) => Ok(Value::Float(a.min(*b as f64))),
_ => Err(crate::safety::arg_err("min requires numeric arguments")),
}
}
/// max(a, b) - Get maximum of two numbers
/// Args:
/// - a: Number
/// - b: Number
/// Returns: Number - Maximum value
///
/// Example:
/// max(3, 7) # Returns 7
fn bi_max(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
if let Some(items) = aggregate_operand(&args, &input) {
return fold_extremum(items, true);
}
if args.len() < 2 {
return Err(crate::safety::arg_err(
"max requires two numbers, one array, or a piped array",
));
}
match (&args[0], &args[1]) {
(Value::Int(a), Value::Int(b)) => Ok(Value::Int(*a.max(b))),
(Value::Float(a), Value::Float(b)) => Ok(Value::Float(a.max(*b))),
(Value::Int(a), Value::Float(b)) => Ok(Value::Float((*a as f64).max(*b))),
(Value::Float(a), Value::Int(b)) => Ok(Value::Float(a.max(*b as f64))),
_ => Err(crate::safety::arg_err("max requires numeric arguments")),
}
}
/// floor(number) - Round down to nearest integer
/// Args:
/// - number: Number
/// Returns: Int - Rounded down value
///
/// Example:
/// floor(3.7) # Returns 3
fn bi_floor(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let val = if args.is_empty() {
input.ok_or_else(|| crate::safety::arg_err("floor requires number argument"))?
} else {
args[0].clone()
};
match val {
Value::Int(n) => Ok(Value::Int(n)),
Value::Float(f) => Ok(Value::Int(f.floor() as i64)),
_ => Err(crate::safety::arg_err("floor requires numeric input")),
}
}
/// ceil(number) - Round up to nearest integer
/// Args:
/// - number: Number
/// Returns: Int - Rounded up value
///
/// Example:
/// ceil(3.2) # Returns 4
fn bi_ceil(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let val = if args.is_empty() {
input.ok_or_else(|| crate::safety::arg_err("ceil requires number argument"))?
} else {
args[0].clone()
};
match val {
Value::Int(n) => Ok(Value::Int(n)),
Value::Float(f) => Ok(Value::Int(f.ceil() as i64)),
_ => Err(crate::safety::arg_err("ceil requires numeric input")),
}
}
/// round(number) - Round to nearest integer
/// Args:
/// - number: Number
/// Returns: Int - Rounded value
///
/// Example:
/// round(3.5) # Returns 4
fn bi_round(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
// In a pipeline the piped value is the subject and the arguments are
// parameters -- `xs | map(f)`, `xs | nth(2)`. `round` inherited the unary
// convention "first argument if present, else the input", so
// `mean | round(2)` took 2 as the number to round and answered 2. It is
// the same defect as discarding `digits`, wearing a pipe.
let piped_subject = input.is_some() && args.len() == 1;
let (val, digit_arg) = if piped_subject {
(input.clone().expect("checked"), args.first())
} else if args.is_empty() {
(
input.ok_or_else(|| crate::safety::arg_err("round requires number argument"))?,
None,
)
} else {
(args[0].clone(), args.get(1))
};
// `round(x, digits)` used to parse, run, and silently discard `digits`:
// `round(4.966, 2)` answered `5`. A wrong number returned without an error
// is the worst shape a failure can take for an agent, because nothing in
// the result says to look again. Absent or zero `digits` keeps the old
// Int-returning behaviour exactly.
let digits = match digit_arg {
None => None,
Some(Value::Int(d)) if (0..=17).contains(d) => Some(*d as u32),
Some(other) => {
return Err(crate::safety::arg_err(&format!(
"round: digits must be an integer in 0..=17, got {}",
other.type_name()
)))
}
};
match (val, digits) {
(Value::Int(n), _) => Ok(Value::Int(n)),
(Value::Float(f), None) | (Value::Float(f), Some(0)) => Ok(Value::Int(f.round() as i64)),
(Value::Float(f), Some(d)) => {
let scale = 10f64.powi(d as i32);
Ok(Value::Float((f * scale).round() / scale))
}
_ => Err(crate::safety::arg_err("round requires numeric input")),
}
}
/// sqrt(number) - Calculate square root
/// Args:
/// - number: Number
/// Returns: Float - Square root
///
/// Example:
/// sqrt(16) # Returns 4.0
fn bi_sqrt(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let val = if args.is_empty() {
input.ok_or_else(|| crate::safety::arg_err("sqrt requires number argument"))?
} else {
args[0].clone()
};
let num = match val {
Value::Int(n) => n as f64,
Value::Float(f) => f,
_ => return Err(crate::safety::arg_err("sqrt requires numeric input")),
};
if num < 0.0 {
return Err(crate::safety::arg_err("sqrt requires non-negative number"));
}
Ok(Value::Float(num.sqrt()))
}
/// pow(base, exponent) - Calculate power
/// Args:
/// - base: Number
/// - exponent: Number
/// Returns: Number - base ^ exponent
///
/// Example:
/// pow(2, 8) # Returns 256
fn bi_pow(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
if args.len() < 2 {
return Err(crate::safety::arg_err(
"pow requires two arguments: base and exponent",
));
}
let base = match &args[0] {
Value::Int(n) => *n as f64,
Value::Float(f) => *f,
_ => return Err(crate::safety::arg_err("pow requires numeric base")),
};
let exp = match &args[1] {
Value::Int(n) => *n as f64,
Value::Float(f) => *f,
_ => return Err(crate::safety::arg_err("pow requires numeric exponent")),
};
let result = base.powf(exp);
// Return Int if result is a whole number and both inputs were Int
if matches!((&args[0], &args[1]), (Value::Int(_), Value::Int(_))) && result.fract() == 0.0 {
Ok(Value::Int(result as i64))
} else {
Ok(Value::Float(result))
}
}
// ==================== Utility Functions ====================
/// exit(code?) - Exit the shell process with a status.
/// Args:
/// - code: Int (optional) - 0..=255, default 0
/// Returns: never; the process ends
///
/// A code outside 0..=255 was truncated by the OS (256 exited 0, -1 exited
/// 255), and pipeline input and surplus arguments were dropped. All three are
/// refused before anything ends.
///
/// Example:
/// exit(0) # Exit with success
fn bi_exit(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("exit", &input)?;
if args.len() > 1 {
return Err(crate::safety::bad_arg(
"exit",
"at most one argument, the exit code",
&format!("{} arguments", args.len()),
));
}
let code = match args.first() {
None => 0,
Some(Value::Int(n)) if (0..=255).contains(n) => *n as i32,
Some(Value::Int(n)) => {
return Err(crate::safety::bad_arg(
"exit",
"code: Int 0..=255",
&n.to_string(),
))
}
Some(other) => {
return Err(crate::safety::bad_arg(
"exit",
"code: Int 0..=255",
other.type_name(),
))
}
};
std::process::exit(code);
}
/// env(key) - Get environment variable
/// Args:
/// - key: String - Environment variable name
/// Returns: String or Null - Variable value or null if not set
///
/// Example:
/// env("HOME") # Returns home directory path
fn bi_env(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
if args.is_empty() {
return Err(crate::safety::arg_err(
"env requires variable name argument",
));
}
let key = expect_string("env", &args[0])?;
match env_read("env", key, std::env::var(key))? {
Some(val) => Ok(gate_env_secret(key, val)),
// `env(name, default)` returns `default` when the variable is unset.
//
// The second argument used to be accepted and discarded, so an unset
// variable was always `null` — the same defect shape as `round(x, n)`
// ignoring `n`. It matters most in bash compatibility mode, where
// `echo $NOPE` must print an empty line as bash does, not the word
// "null".
None => Ok(args.get(1).cloned().unwrap_or(Value::Null)),
}
}
/// Gate an env-var read against the secret policy (§7.6): in agent mode, reading
/// a secret-named var (e.g. `*_KEY`, `*TOKEN*`) returns an opaque
/// `[REDACTED:NAME]` handle instead of the value, so the credential never enters
/// the program's value space — unless `IRONSHELL_SECRETS=allow`. Human mode and
/// non-secret names return the value unchanged.
fn gate_env_secret(key: &str, val: String) -> Value {
if crate::safety::env_secret_gated(key) {
Value::Str(format!("[REDACTED:{}]", key))
} else {
Value::Str(val)
}
}
/// set_env(key, value) - Set environment variable
/// Args:
/// - key: String - Environment variable name
/// - value: String - Value to set
/// Returns: Null
///
/// Example:
/// set_env("MY_VAR", "value")
fn bi_set_env(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "set_env";
k8s_no_input(B, &input)?;
if args.len() < 2 {
return Err(crate::safety::arg_err(
"set_env requires key and value arguments",
));
}
let key = env_name(B, &args[0])?;
refuse_gate_env(B, &key)?;
let value = env_value(B, &args[1])?;
std::env::set_var(key, value);
Ok(Value::Null)
}
/// An environment variable name. `set_var` panics on an empty name, one with
/// '=' or NUL, and a value with NUL, so each is refused here as E_BAD_ARG.
fn env_name(builtin: &str, value: &Value) -> Result<String> {
let name = expect_string(builtin, value)?;
if name.is_empty() || name.len() > 1024 || name.contains(['=', '\0']) {
return Err(crate::safety::bad_arg(
builtin,
"a name of 1 to 1024 bytes with no '=' and no NUL",
&format!("{name:?}"),
));
}
Ok(name.to_string())
}
/// An environment variable value: a string with no NUL, at most 32767 bytes
/// (the Windows limit per variable).
fn env_value(builtin: &str, value: &Value) -> Result<String> {
let text = expect_string(builtin, value)?;
if text.contains('\0') || text.len() > 32_767 {
return Err(crate::safety::bad_arg(
builtin,
"a value of at most 32767 bytes with no NUL",
"a value that is too long or has NUL",
));
}
Ok(text.to_string())
}
/// Refuse, in agent mode, any change to a variable that steers a child program
/// or the destination of data (see `safety::env_name_steers_children`).
///
/// The approval gate and its mode switch are read from the process
/// environment on every call: `IRONSHELL_MODE`/`IRONSHELL_AGENT` decide whether
/// `approve()` is refused, `IRONSHELL_APPROVE`/`IRONSHELL_APPROVE_ALL` decide whether a
/// gated call (including `apply`) is approved, and `IRONSHELL_POLICY`,
/// `IRONSHELL_WORKSPACE` and the `IRONSHELL_MAX_*` budgets decide the rest. An agent
/// that could set or unset those through `set_env`/`env_set`/`env_unset` (all
/// classified WriteLocal, so allowed in agent mode) could approve its own plan
/// or switch the gate off. Fail closed: the agent may not touch them. Human
/// mode is unchanged.
fn refuse_gate_env(builtin: &str, name: &str) -> Result<()> {
if crate::safety::env_change_refused(name) {
return Err(crate::safety::policy_deny(
builtin,
format!(
"{builtin}: an agent may not change {name}: it steers the approval gate, \
a child program, its loader or configuration, or where data is sent"
),
"variables that steer child programs and network destinations (PATH, GIT_*, \
IRONGATE_*, *_PROXY, provider keys and endpoints, ...) are set by whoever \
launches the agent, not by the agent",
));
}
Ok(())
}
/// sleep(seconds) - Sleep for specified duration
/// Args:
/// - seconds: Number - Duration to sleep
/// Returns: Null
///
/// Example:
/// sleep(2) # Sleep for 2 seconds
fn bi_sleep(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
if args.is_empty() {
return Err(crate::safety::arg_err("sleep requires duration argument"));
}
let seconds = match &args[0] {
Value::Int(n) => *n as f64,
Value::Float(f) => *f,
_ => return Err(crate::safety::arg_err("sleep requires numeric duration")),
};
let duration = Duration::try_from_secs_f64(seconds).map_err(|_| {
crate::safety::arg_err("sleep: duration must be finite, non-negative and representable")
})?;
// A sleep may not outlast the evaluation deadline. Sleeping only what is
// left and then failing tells the caller the time was cut short, rather
// than returning Null for a wait that did not happen in full.
if let Some(left) = crate::safety::remaining_deadline() {
if left < duration {
let message = if left.is_zero() {
format!("sleep of {seconds} s refused: the evaluation deadline has already expired")
} else {
std::thread::sleep(left);
format!("sleep of {seconds} s cut short by the evaluation deadline after {left:?}")
};
return Err(crate::safety::budget_exceeded(
"sleep",
message,
"ask for a shorter sleep, or allow a longer evaluation time limit",
));
}
}
std::thread::sleep(duration);
Ok(Value::Null)
}
/// time() - Get current Unix timestamp
/// Returns: Int - Current Unix timestamp in seconds
///
/// Example:
/// time() # Returns 1699401234
fn bi_time(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
// `now` and `time` are one clock under two names, declared identically
// in the ontology; one implementation keeps them from drifting apart.
bi_now(args, input)
}
/// json_parse(json_string?) - Parse JSON string into value
/// Args:
/// - json_string: String (optional if piped)
/// Returns: Any - Parsed JSON value
///
/// Example:
/// json_parse("{\"a\":1}") # Returns {a: 1}
fn bi_json_parse(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let json_str = if args.is_empty() {
let input_val =
input.ok_or_else(|| crate::safety::arg_err("json_parse requires JSON string"))?;
expect_string("json_parse", &input_val)?.to_string()
} else {
expect_string("json_parse", &args[0])?.to_string()
};
let json_val: serde_json::Value = serde_json::from_str(&json_str)
.map_err(|e| crate::safety::bad_arg("json_parse", "valid JSON", &e.to_string()))?;
Ok(json_to_value(json_val))
}
/// json_stringify(value?) - Convert value to JSON string
/// Args:
/// - value: Any (optional if piped)
/// Returns: String - JSON representation
///
/// Example:
/// {a: 1} | json_stringify() # Returns "{\"a\":1}"
fn bi_json_stringify(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let val = if args.is_empty() {
input.ok_or_else(|| crate::safety::arg_err("json_stringify requires value"))?
} else {
args[0].clone()
};
let json_val = value_to_json(val);
let json_str = serde_json::to_string(&json_val).context("Failed to stringify to JSON")?;
Ok(Value::Str(json_str))
}
// ==================== Syntax Knowledge Base Functions ====================
use crate::syntax_kb::{AgenticBinary, SyntaxCategory, SyntaxEntry, SyntaxKB};
use std::sync::{Mutex, OnceLock};
// Global syntax knowledge base
static SYNTAX_KB: OnceLock<Mutex<SyntaxKB>> = OnceLock::new();
// Why ~/.ironshell/syntax_kb.json did not load, when the file exists but cannot
// be read or parsed. None when storage is live. Set by the first
// get_syntax_kb(); the knowledge base then runs in memory and saves nothing.
static SYNTAX_KB_LOAD_ERROR: OnceLock<Option<String>> = OnceLock::new();
/// The reason the syntax knowledge base is not persisted, or None when its
/// storage loaded. Forces the load first, so the answer is never stale.
fn syntax_kb_load_error() -> Option<&'static str> {
let _ = get_syntax_kb();
SYNTAX_KB_LOAD_ERROR.get().and_then(|e| e.as_deref())
}
fn get_syntax_kb() -> &'static Mutex<SyntaxKB> {
SYNTAX_KB.get_or_init(|| {
// Try to load from ~/.ironshell/syntax_kb.json
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| ".".to_string());
let storage_path = std::path::PathBuf::from(home)
.join(".ironshell")
.join("syntax_kb.json");
// Create directory if it doesn't exist
if let Some(parent) = storage_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let (kb, load_error) = match SyntaxKB::with_storage(storage_path.clone()) {
Ok(kb) => (kb, None),
Err(e) => (
SyntaxKB::new(),
Some(format!(
"the syntax knowledge base file {} did not load ({e})",
storage_path.display()
)),
),
};
let _ = SYNTAX_KB_LOAD_ERROR.set(load_error);
Mutex::new(kb)
})
}
/// syntax_get(id) - Get syntax entry by ID
/// Args:
/// - id: String - Syntax ID
/// Returns: Record - Syntax entry details
///
/// Example:
/// syntax_get("ab") # Returns AgenticBinary protocol details
fn bi_syntax_get(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let id_val = input
.or_else(|| args.first().cloned())
.ok_or_else(|| crate::safety::arg_err("syntax_get requires syntax ID"))?;
let id = expect_string("syntax_get", &id_val)?;
let kb = get_syntax_kb().lock().unwrap();
let entry = kb
.get(id)
.ok_or_else(|| crate::safety::not_found("syntax_get", "syntax", id))?;
// Convert to Value::Record
let mut fields = std::collections::BTreeMap::new();
fields.insert("id".to_string(), Value::Str(entry.id.clone()));
fields.insert("name".to_string(), Value::Str(entry.name.clone()));
fields.insert(
"category".to_string(),
Value::Str(format!("{:?}", entry.category)),
);
fields.insert(
"specification".to_string(),
Value::Str(entry.specification.clone()),
);
fields.insert(
"examples".to_string(),
Value::Array(
entry
.examples
.iter()
.map(|s| Value::Str(s.clone()))
.collect(),
),
);
if let Some(enc) = &entry.binary_encoding {
let mut enc_fields = std::collections::BTreeMap::new();
enc_fields.insert("name".to_string(), Value::Str(enc.name.clone()));
enc_fields.insert("bit_layout".to_string(), Value::Str(enc.bit_layout.clone()));
fields.insert("binary_encoding".to_string(), Value::Record(enc_fields));
}
Ok(Value::Record(fields))
}
/// syntax_list(category?) - List all syntax entries or by category
/// Args:
/// - category: String (optional) - Category to filter by
/// Returns: Array - List of syntax IDs
///
/// Example:
/// syntax_list() # Returns all syntax IDs
/// syntax_list("protocol") # Returns protocol syntax IDs
fn bi_syntax_list(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let kb = get_syntax_kb().lock().unwrap();
let ids = if let Some(cat_val) = input.or_else(|| args.first().cloned()) {
let category_str = expect_string("syntax_list", &cat_val)?;
let category = match category_str.to_lowercase().as_str() {
"protocol" => SyntaxCategory::Protocol,
"language" => SyntaxCategory::Language,
"encoding" => SyntaxCategory::Encoding,
"command" => SyntaxCategory::Command,
"query" => SyntaxCategory::Query,
other => SyntaxCategory::Custom(other.to_string()),
};
kb.list_by_category(&category)
.iter()
.map(|e| Value::Str(e.id.clone()))
.collect()
} else {
kb.list_all_ids()
.iter()
.map(|id| Value::Str(id.clone()))
.collect()
};
Ok(Value::Array(ids))
}
/// syntax_search(query) - Search syntax entries by keyword
/// Args:
/// - query: String - Search query
/// Returns: Array - Matching syntax IDs
///
/// Example:
/// syntax_search("binary") # Finds entries with "binary" in name/spec
fn bi_syntax_search(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let query_val = input
.or_else(|| args.first().cloned())
.ok_or_else(|| crate::safety::arg_err("syntax_search requires query string"))?;
let query = expect_string("syntax_search", &query_val)?;
let kb = get_syntax_kb().lock().unwrap();
let results: Vec<Value> = kb
.search(query)
.iter()
.map(|e| Value::Str(e.id.clone()))
.collect();
Ok(Value::Array(results))
}
/// syntax_add(entry_record) - Add a new syntax entry
/// Args:
/// - entry_record: Record - Syntax entry with id, name, category, specification
/// Returns: Bool - Success status
///
/// Example:
/// syntax_add({id: "mysyntax", name: "My Syntax", category: "custom", specification: "..."})
fn bi_syntax_add(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "syntax_add";
// The entry comes either piped or as the argument, never both: the piped
// form used to win and drop the argument without a word.
let entry_val = match (input, args.as_slice()) {
(Some(piped), []) => piped,
(None, [direct]) => direct.clone(),
(Some(_), [_, ..]) => {
return Err(crate::safety::arg_err(
"syntax_add takes its entry piped or as its argument, not both",
))
}
(None, []) => return Err(crate::safety::arg_err("syntax_add requires entry record")),
(None, _) => return Err(crate::safety::arg_err("syntax_add takes one entry record")),
};
let record = match entry_val {
Value::Record(r) => r,
other => {
return Err(crate::safety::bad_arg(
B,
"entry: Record",
other.type_name(),
))
}
};
// A misspelt field (`exmples`) used to be dropped, and the entry saved
// without it.
if let Some(unknown) = record.keys().find(|k| {
!matches!(
k.as_str(),
"id" | "name" | "category" | "specification" | "examples"
)
}) {
return Err(crate::safety::bad_arg(
B,
"a Record with only the fields id, name, category, specification, examples",
&format!("the field {unknown:?}"),
));
}
// A present field of the wrong type is refused; an absent one falls to its
// default or to the required-field message below.
let text_field = |key: &str| -> Result<Option<String>> {
match record.get(key) {
None => Ok(None),
Some(Value::Str(s)) => Ok(Some(s.clone())),
Some(other) => Err(crate::safety::bad_arg(
B,
&format!("{key}: String"),
other.type_name(),
)),
}
};
let id = text_field("id")?
.ok_or_else(|| crate::safety::arg_err("syntax_add requires 'id' field"))?;
let well_formed_id = !id.is_empty()
&& id.len() <= 128
&& id
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'));
if !well_formed_id {
return Err(crate::safety::bad_arg(
B,
"id: 1 to 128 letters, digits, '_', '-' or '.'",
&format!("{id:?}"),
));
}
let name = text_field("name")?
.ok_or_else(|| crate::safety::arg_err("syntax_add requires 'name' field"))?;
if name.trim().is_empty() {
return Err(crate::safety::bad_arg(
B,
"name: a non-blank String",
"a blank name",
));
}
let category_str = text_field("category")?.unwrap_or_else(|| "custom".to_string());
let category = match category_str.to_lowercase().as_str() {
"protocol" => SyntaxCategory::Protocol,
"language" => SyntaxCategory::Language,
"encoding" => SyntaxCategory::Encoding,
"command" => SyntaxCategory::Command,
"query" => SyntaxCategory::Query,
"" => {
return Err(crate::safety::bad_arg(
B,
"category: a nonblank String",
"an empty category",
))
}
other => SyntaxCategory::Custom(other.to_string()),
};
let specification = text_field("specification")?
.ok_or_else(|| crate::safety::arg_err("syntax_add requires 'specification' field"))?;
let examples: Vec<String> = match record.get("examples") {
None => Vec::new(),
Some(Value::Array(arr)) => arr
.iter()
.map(|v| match v {
Value::Str(s) => Ok(s.clone()),
other => Err(crate::safety::bad_arg(
B,
"examples: Array of String",
other.type_name(),
)),
})
.collect::<Result<_>>()?,
Some(other) => {
return Err(crate::safety::bad_arg(
B,
"examples: Array of String",
other.type_name(),
))
}
};
let entry = SyntaxEntry {
id: id.clone(),
name,
category,
specification,
examples,
binary_encoding: None,
metadata: std::collections::HashMap::new(),
};
if let Some(why) = syntax_kb_load_error() {
return Err(crate::safety::bad_state_with_hint(
B,
&format!("{why}; the entry was not saved and the file was left untouched"),
"repair or move the syntax knowledge base file so it loads, then retry; the shell does not overwrite it",
));
}
let mut kb = get_syntax_kb().lock().map_err(|_| {
crate::safety::bad_state(
B,
"the syntax knowledge base is poisoned by an earlier failure",
"syntax_list",
)
})?;
// add_entry replaces an entry with the same id and lists the id twice in
// its category, so an existing id is refused rather than overwritten.
if kb.get(&id).is_some() {
return Err(crate::safety::bad_arg(
B,
"an id not yet in the syntax knowledge base",
&format!("the existing id {id:?}"),
));
}
kb.add_entry(entry).map_err(|e| {
crate::safety::fs_error(B, "syntax_kb.json", &std::io::Error::other(e.to_string()))
})?;
Ok(Value::Bool(true))
}
/// syntax_categories() - List all available syntax categories
/// Returns: Array - List of category names
///
/// Example:
/// syntax_categories() # Returns ["protocol", "language", ...]
fn bi_syntax_categories(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let kb = get_syntax_kb().lock().unwrap();
let categories: Vec<Value> = kb
.list_categories()
.iter()
.map(|c| Value::Str(c.clone()))
.collect();
Ok(Value::Array(categories))
}
/// ab_encode(msg_type, opcode, payload) - Encode message using AgenticBinary protocol
/// Args:
/// - msg_type: String or Int - Message type (Command/Query/Response/Event or 0-3)
/// - opcode: String or Int - Opcode name or number (0-15)
/// - payload: String - Payload data
/// Returns: Array - Encoded bytes
///
/// Example:
/// ab_encode("Query", "DATA", "hello") # Encodes AgenticBinary message
/// ab_encode(1, 4, "hello") # Same using numeric codes
fn bi_ab_encode(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
if args.len() < 3 {
return Err(crate::safety::arg_err(
"ab_encode requires 3 arguments: msg_type, opcode, payload",
));
}
// Parse message type
let msg_type = match &args[0] {
Value::Int(i) if (0..=3).contains(i) => *i as u8,
Value::Str(s) => match s.to_lowercase().as_str() {
"command" | "cmd" => 0,
"query" => 1,
"response" | "resp" => 2,
"event" => 3,
_ => return Err(crate::safety::arg_err(format!("Invalid message type: {s}"))),
},
_ => return Err(crate::safety::arg_err("msg_type must be String or Int")),
};
// Parse opcode
let opcode = match &args[1] {
Value::Int(i) if (0..=15).contains(i) => *i as u8,
Value::Str(s) => match s.to_uppercase().as_str() {
"PING" => 0x0,
"ACK" => 0x1,
"QUERY" => 0x2,
"EXEC" => 0x3,
"DATA" => 0x4,
"ERROR" => 0x5,
"SYNC" => 0x6,
"AUTH" => 0x7,
"DELEGATE" => 0x8,
"COLLABORATE" => 0x9,
"LEARN" => 0xA,
"REASON" => 0xB,
"PLAN" => 0xC,
"OBSERVE" => 0xD,
"REFLECT" => 0xE,
"EXTEND" => 0xF,
_ => return Err(crate::safety::arg_err(format!("Invalid opcode: {s}"))),
},
_ => return Err(crate::safety::arg_err("opcode must be String or Int")),
};
// Get payload
let payload = expect_string("ab_encode", &args[2])?;
// Encode using AgenticBinary (version 0)
let encoded = AgenticBinary::encode(0, msg_type, opcode, payload.as_bytes())?;
// Convert to Value::Array of Int values
let result: Vec<Value> = encoded.iter().map(|&b| Value::Int(b as i64)).collect();
Ok(Value::Array(result))
}
/// ab_decode(bytes) - Decode AgenticBinary message
/// Args:
/// - bytes: Array - Byte array to decode
/// Returns: Record - Decoded message with version, msg_type, opcode, payload
///
/// Example:
/// ab_decode([20, 5, 104, 101, 108, 108, 111]) # Decodes to record
fn bi_ab_decode(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let bytes_val = input
.or_else(|| args.first().cloned())
.ok_or_else(|| crate::safety::arg_err("ab_decode requires byte array"))?;
let bytes_arr = expect_array("ab_decode", &bytes_val)?;
let bytes: Vec<u8> = bytes_arr
.iter()
.map(|v| match v {
Value::Int(i) if (0..=255).contains(i) => Ok(*i as u8),
_ => Err(crate::safety::arg_err(
"Byte array must contain integers in 0..255",
)),
})
.collect::<Result<Vec<u8>>>()?;
let (version, msg_type, opcode, payload) = AgenticBinary::decode(&bytes)
.map_err(|error| crate::safety::arg_err(format!("ab_decode: {error}")))?;
let mut result = std::collections::BTreeMap::new();
result.insert("version".to_string(), Value::Int(version as i64));
result.insert(
"msg_type".to_string(),
Value::Str(AgenticBinary::msg_type_name(msg_type).to_string()),
);
result.insert("msg_type_code".to_string(), Value::Int(msg_type as i64));
result.insert(
"opcode".to_string(),
Value::Str(AgenticBinary::opcode_name(opcode).to_string()),
);
result.insert("opcode_code".to_string(), Value::Int(opcode as i64));
result.insert(
"payload".to_string(),
Value::Str(String::from_utf8_lossy(&payload).to_string()),
);
result.insert(
"payload_bytes".to_string(),
Value::Array(payload.iter().map(|&b| Value::Int(b as i64)).collect()),
);
Ok(Value::Record(result))
}
// --------------- Neural Network Builtins ---------------
/// nn_create(name, layer_sizes, [hidden_activation], [output_activation])
/// Creates a feedforward neural network
/// Args:
/// - name: String - Network name/identifier
/// - layer_sizes: Array[Int] - Sizes of each layer [input, hidden..., output]
/// - hidden_activation: String (optional) - "relu", "sigmoid", "tanh", "swish" (default: "relu")
/// - output_activation: String (optional) - activation for output layer (default: "tanh")
/// Returns: Record - Network representation
///
/// Example:
/// nn_create("policy", [8, 16, 8, 4]) # 8 inputs, 2 hidden layers, 4 outputs
fn bi_nn_create(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
if args.is_empty() {
return Err(crate::safety::arg_err(
"nn_create requires at least name and layer_sizes",
));
}
let name = expect_string("nn_create", &args[0])?;
let layer_sizes_val = args
.get(1)
.ok_or_else(|| crate::safety::arg_err("nn_create requires layer_sizes array"))?;
let layer_sizes_arr = expect_array("nn_create", layer_sizes_val)?;
let layer_sizes: Vec<usize> = layer_sizes_arr
.iter()
.map(|v| {
let n = match v {
Value::Int(n) => *n,
other => {
return Err(crate::safety::bad_arg(
"nn_create",
"layer size: Int",
other.type_name(),
))
}
};
usize::try_from(n).ok().filter(|&n| n > 0).ok_or_else(|| {
crate::safety::arg_err("nn_create: layer sizes must be positive integers")
})
})
.collect::<Result<Vec<_>>>()?;
if layer_sizes.len() < 2 {
return Err(crate::safety::arg_err(
"nn_create requires at least 2 layers (input and output)",
));
}
let mut count = 0usize;
for sizes in layer_sizes.windows(2) {
count = checked_nn_parameter_count(count, sizes[0], sizes[1])?;
}
let parse_activation = |value: &Value| {
let name = expect_string("nn_create", value)?;
Activation::from_str(name).ok_or_else(|| {
crate::safety::bad_arg(
"nn_create",
"relu, sigmoid, tanh, softmax, linear, swish or leaky_relu",
name,
)
})
};
let hidden_activation = args
.get(2)
.map(parse_activation)
.transpose()?
.unwrap_or(Activation::ReLU);
let output_activation = args
.get(3)
.map(parse_activation)
.transpose()?
.unwrap_or(Activation::Tanh);
let network =
NeuralNetwork::feedforward(name, &layer_sizes, hidden_activation, output_activation);
nn_to_value(&network)
}
/// nn_forward(network, input)
/// Forward pass through the network
/// Args:
/// - network: Record - Network from nn_create
/// - input: Array[Float] - Input values
/// Returns: Array[Float] - Output values
fn bi_nn_forward(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let has_input = input.is_some();
let network_val = input
.or_else(|| args.first().cloned())
.ok_or_else(|| crate::safety::arg_err("nn_forward requires network"))?;
let network = value_to_nn(&network_val)?;
let input_val = args
.get(if has_input { 0 } else { 1 })
.ok_or_else(|| crate::safety::arg_err("nn_forward requires input array"))?;
let input_arr = expect_array("nn_forward", input_val)?;
let input_vec: Vec<f64> = input_arr
.iter()
.map(|v| finite_nn_number("nn_forward", v))
.collect::<Result<Vec<_>>>()?;
if input_vec.len() != network.layers[0].input_size {
return Err(crate::safety::arg_err(
"nn_forward: input length must equal the network input size",
));
}
let output = network.forward(&input_vec);
if output.iter().any(|n| !n.is_finite()) {
return Err(crate::safety::bad_state(
"nn_forward",
"non-finite output",
"the computation exceeded the numeric range",
));
}
Ok(Value::Array(output.into_iter().map(Value::Float).collect()))
}
/// nn_mutate(network, rate, strength)
/// Mutate network parameters
/// Args:
/// - network: Record - Network to mutate
/// - rate: Float - Mutation probability (0.0-1.0)
/// - strength: Float - Mutation magnitude
/// Returns: Record - Mutated network
fn bi_nn_mutate(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let has_input = input.is_some();
let network_val = input
.or_else(|| args.first().cloned())
.ok_or_else(|| crate::safety::arg_err("nn_mutate requires network"))?;
let network = value_to_nn(&network_val)?;
let idx = if has_input { 0 } else { 1 };
let rate = args
.get(idx)
.map(|v| finite_nn_number("nn_mutate", v))
.transpose()?
.unwrap_or(0.1);
let strength = args
.get(idx + 1)
.map(|v| finite_nn_number("nn_mutate", v))
.transpose()?
.unwrap_or(0.3);
if !(0.0..=1.0).contains(&rate) || strength < 0.0 {
return Err(crate::safety::arg_err(
"nn_mutate: rate must be in 0..=1 and strength non-negative",
));
}
let mutated = network.mutate(rate, strength);
validate_nn(&mutated)?;
nn_to_value(&mutated)
}
/// nn_crossover(network1, network2)
/// Crossover two networks
/// Args:
/// - network1: Record - First parent network
/// - network2: Record - Second parent network
/// Returns: Record - Child network
fn bi_nn_crossover(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
if args.len() < 2 {
return Err(crate::safety::arg_err("nn_crossover requires two networks"));
}
let net1 = value_to_nn(&args[0])?;
let net2 = value_to_nn(&args[1])?;
if net1.layers.len() != net2.layers.len()
|| net1
.layers
.iter()
.zip(&net2.layers)
.any(|(a, b)| a.input_size != b.input_size || a.output_size != b.output_size)
{
return Err(crate::safety::arg_err(
"nn_crossover: parent layer dimensions must match",
));
}
let child = NeuralNetwork::crossover(&net1, &net2);
nn_to_value(&child)
}
/// nn_params(network)
/// Get network parameters as flat array
/// Args:
/// - network: Record - Network
/// Returns: Array[Float] - All weights and biases
fn bi_nn_params(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let network_val = input
.or_else(|| args.first().cloned())
.ok_or_else(|| crate::safety::arg_err("nn_params requires network"))?;
let network = value_to_nn(&network_val)?;
let params = network.get_params();
Ok(Value::Array(params.into_iter().map(Value::Float).collect()))
}
/// nn_set_params(network, params)
/// Set network parameters from flat array
/// Args:
/// - network: Record - Network
/// - params: Array[Float] - New parameters
/// Returns: Record - Updated network
fn bi_nn_set_params(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let has_input = input.is_some();
let network_val = input
.or_else(|| args.first().cloned())
.ok_or_else(|| crate::safety::arg_err("nn_set_params requires network"))?;
let mut network = value_to_nn(&network_val)?;
let idx = if has_input { 0 } else { 1 };
let params_val = args
.get(idx)
.ok_or_else(|| crate::safety::arg_err("nn_set_params requires params array"))?;
let params_arr = expect_array("nn_set_params", params_val)?;
let params: Vec<f64> = params_arr
.iter()
.map(|v| finite_nn_number("nn_set_params", v))
.collect::<Result<Vec<_>>>()?;
if params.len() != network.param_count() {
return Err(crate::safety::arg_err(
"nn_set_params: parameter length must equal the network parameter count",
));
}
network.set_params(¶ms);
nn_to_value(&network)
}
/// nn_layers(network)
/// Get layer information
/// Args:
/// - network: Record - Network
/// Returns: Array[Record] - Layer specifications
fn bi_nn_layers(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let network_val = input
.or_else(|| args.first().cloned())
.ok_or_else(|| crate::safety::arg_err("nn_layers requires network"))?;
let network = value_to_nn(&network_val)?;
let layers: Vec<Value> = network
.layers
.iter()
.map(|layer| {
let mut rec = BTreeMap::new();
rec.insert(
"input_size".to_string(),
Value::Int(layer.input_size as i64),
);
rec.insert(
"output_size".to_string(),
Value::Int(layer.output_size as i64),
);
rec.insert(
"activation".to_string(),
Value::Str(format!("{:?}", layer.activation)),
);
rec.insert(
"param_count".to_string(),
Value::Int(layer.param_count() as i64),
);
Value::Record(rec)
})
.collect();
Ok(Value::Array(layers))
}
/// nn_info(network)
/// Get network summary information
/// Args:
/// - network: Record - Network
/// Returns: Record - Network info including total params, layer count, etc.
fn bi_nn_info(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let network_val = input
.or_else(|| args.first().cloned())
.ok_or_else(|| crate::safety::arg_err("nn_info requires network"))?;
let network = value_to_nn(&network_val)?;
let mut info = BTreeMap::new();
info.insert("name".to_string(), Value::Str(network.name.clone()));
info.insert(
"layer_count".to_string(),
Value::Int(network.layers.len() as i64),
);
info.insert(
"param_count".to_string(),
Value::Int(network.param_count() as i64),
);
if let Some(first) = network.layers.first() {
info.insert(
"input_size".to_string(),
Value::Int(first.input_size as i64),
);
}
if let Some(last) = network.layers.last() {
info.insert(
"output_size".to_string(),
Value::Int(last.output_size as i64),
);
}
Ok(Value::Record(info))
}
/// consensus_net(name, agent_count, input_size, hidden_size, output_size)
/// Create a consensus network for distributed decision making
/// Args:
/// - name: String - Network name
/// - agent_count: Int - Number of agents
/// - input_size: Int - Input dimension
/// - hidden_size: Int - Hidden layer size
/// - output_size: Int - Output dimension (decision size)
/// Returns: Record - Consensus network
fn bi_consensus_net(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
if args.len() < 5 {
return Err(crate::safety::arg_err(
"consensus_net requires name, agent_count, input_size, hidden_size, output_size",
));
}
let name = expect_string("consensus_net", &args[0])?;
let size = |value: &Value| -> Result<usize> {
let n = expect_int("consensus_net", value)?;
usize::try_from(n).ok().filter(|n| *n > 0).ok_or_else(|| {
crate::safety::arg_err("consensus_net sizes must be positive representable integers")
})
};
let agent_count = size(&args[1])?;
let input_size = size(&args[2])?;
let hidden_size = size(&args[3])?;
let output_size = size(&args[4])?;
if agent_count > 1024 {
return Err(crate::safety::budget_exceeded(
"consensus_net",
"supports at most 1024 agents".into(),
"use fewer agents",
));
}
let twice_input = input_size.checked_mul(2);
let twice_hidden = hidden_size.checked_mul(2);
let decision_input = hidden_size.checked_add(input_size);
let decision_hidden = hidden_size.checked_add(output_size).map(|n| n / 2);
let dimensions = twice_input
.zip(twice_hidden)
.zip(decision_input.zip(decision_hidden))
.ok_or_else(|| crate::safety::arg_err("consensus_net dimension arithmetic overflow"))?;
let ((twice_input, twice_hidden), (decision_input, decision_hidden)) = dimensions;
let mut total = 0;
for (input, output) in [
(input_size, twice_input),
(twice_input, hidden_size),
(twice_hidden, twice_hidden),
(twice_hidden, hidden_size),
(decision_input, decision_hidden),
(decision_hidden, output_size),
] {
total = checked_nn_parameter_count(total, input, output)?;
}
for _ in 0..agent_count {
total = checked_nn_parameter_count(total, input_size, input_size)?;
total = checked_nn_parameter_count(total, input_size, hidden_size)?;
}
let consensus = ConsensusNetwork::new(name, agent_count, input_size, hidden_size, output_size);
consensus_net_to_value(&consensus)
}
/// consensus_vote(network, agent_inputs)
/// Run consensus voting across all agents
/// Args:
/// - network: Record - Consensus network
/// - agent_inputs: Array[Array[Float]] - Each agent's input
/// Returns: Record - {decisions: Array[Array], consensus: Array[Float], confidence: Float}
fn bi_consensus_vote(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let has_input = input.is_some();
let network_val = input
.or_else(|| args.first().cloned())
.ok_or_else(|| crate::safety::arg_err("consensus_vote requires network"))?;
let mut network = value_to_consensus_net(&network_val)?;
let idx = if has_input { 0 } else { 1 };
let inputs_val = args
.get(idx)
.ok_or_else(|| crate::safety::arg_err("consensus_vote requires agent_inputs"))?;
let inputs_arr = expect_array("consensus_vote", inputs_val)?;
let agent_inputs: Vec<Vec<f64>> = inputs_arr
.iter()
.map(|v| {
let arr = expect_array("consensus_vote", v)?;
arr.iter()
.map(|v| finite_nn_number("consensus_vote", v))
.collect()
})
.collect::<Result<Vec<_>>>()?;
if agent_inputs.len() != network.agent_networks.len()
|| agent_inputs
.iter()
.any(|row| row.len() != network.state_dim)
{
return Err(crate::safety::arg_err(
"consensus_vote requires exactly one state_dim input row per agent",
));
}
let (decisions, consensus) = network.consensus(&agent_inputs);
if decisions
.iter()
.flatten()
.chain(consensus.iter())
.any(|n| !n.is_finite())
{
return Err(crate::safety::bad_state(
"consensus_vote",
"non-finite output",
"the computation exceeded the numeric range",
));
}
// Confidence is the reciprocal of one plus the mean squared disagreement.
let decision_dim = decisions.first().map(|d| d.len()).unwrap_or(0);
let mean: Vec<f64> = (0..decision_dim)
.map(|i| {
decisions
.iter()
.map(|d| d.get(i).copied().unwrap_or(0.0))
.sum::<f64>()
/ decisions.len().max(1) as f64
})
.collect();
let variance: f64 = decisions
.iter()
.map(|d: &Vec<f64>| {
d.iter()
.zip(&mean)
.map(|(a, b)| (a - b).powi(2))
.sum::<f64>()
})
.sum::<f64>()
/ decisions.len().max(1) as f64;
let confidence = 1.0 / (1.0 + variance);
let mut result = BTreeMap::new();
result.insert(
"decisions".to_string(),
Value::Array(
decisions
.into_iter()
.map(|d: Vec<f64>| Value::Array(d.into_iter().map(Value::Float).collect()))
.collect(),
),
);
result.insert(
"consensus".to_string(),
Value::Array(consensus.into_iter().map(Value::Float).collect()),
);
result.insert("confidence".to_string(), Value::Float(confidence));
Ok(Value::Record(result))
}
/// activation(name)
/// Get activation function by name
/// Args:
/// - name: String - "relu", "sigmoid", "tanh", "softmax", "linear", "swish", "leaky_relu"
/// Returns: Record - Activation function info
fn bi_activation(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let name = args
.first()
.and_then(|v| {
if let Value::Str(s) = v {
Some(s.as_str())
} else {
None
}
})
.ok_or_else(|| crate::safety::arg_err("activation requires name string"))?;
let activation = Activation::from_str(name).ok_or_else(|| {
crate::safety::bad_arg(
"activation",
"one of: relu, sigmoid, tanh, softmax, linear, swish, leaky_relu",
name,
)
})?;
let mut info = BTreeMap::new();
info.insert("name".to_string(), Value::Str(format!("{:?}", activation)));
info.insert(
"description".to_string(),
Value::Str(
match activation {
Activation::ReLU => "Rectified Linear Unit: max(0, x)",
Activation::Sigmoid => "Sigmoid: 1/(1+e^-x), outputs 0-1",
Activation::Tanh => "Hyperbolic tangent: outputs -1 to 1",
Activation::Softmax => "Softmax: probability distribution",
Activation::Linear => "Linear: f(x) = x",
Activation::LeakyReLU(_) => "Leaky ReLU: x if x>0, else 0.01*x",
Activation::Swish => "Swish: x * sigmoid(x)",
}
.to_string(),
),
);
Ok(Value::Record(info))
}
// --------------- Evolution Builtins ---------------
/// population(size, genome_type, [config])
/// Create a population for evolution
/// Args:
/// - size: Int - Population size
/// - genome_type: String - "nn" for neural network, "vec" for float vector
/// - config: Record (optional) - Evolution configuration
/// Returns: Record - Population state
fn bi_population(args: Vec<Value>, _input: Option<Value>, _env: &mut Env) -> Result<Value> {
if args.len() < 2 {
return Err(crate::safety::arg_err(
"population requires size and genome_type",
));
}
let size_value = expect_int("population", &args[0])?;
let size = usize::try_from(size_value)
.ok()
.filter(|n| *n > 0)
.ok_or_else(|| {
crate::safety::arg_err("population size must be a positive representable Int")
})?;
let genome_type = expect_string("population", &args[1])?;
let config = parse_evolution_options("population", args.get(2), Some(size))?;
// Create population based on genome type
match genome_type {
"nn" => {
let pop: Population<NeuralNetwork> = Population::new(config.clone());
population_to_value(&pop, "nn")
}
"vec" | "vector" => {
let pop: Population<Vec<f64>> = Population::new(config.clone());
population_to_value(&pop, "vec")
}
_ => Err(crate::safety::bad_arg(
"population",
"genome_type: nn, vec or vector",
genome_type,
)),
}
}
/// evolve(population, fitness_fn, generations)
/// Run evolution for multiple generations
/// Args:
/// - population: Record - Population from population()
/// - fitness_fn: Lambda - fn(genome) => Float
/// - generations: Int - Number of generations
/// Returns: Record - Updated population with stats
fn bi_evolve(args: Vec<Value>, input: Option<Value>, env: &mut Env) -> Result<Value> {
evolution_operation("evolve", args, input, env, true, false)
}
/// Run one generation, evaluating uncached parents before reproduction.
fn bi_evolve_step(args: Vec<Value>, input: Option<Value>, env: &mut Env) -> Result<Value> {
evolution_operation("evolve_step", args, input, env, false, false)
}
/// Evaluate uncached individuals and retain their scores for subsequent calls.
fn bi_fitness(args: Vec<Value>, input: Option<Value>, env: &mut Env) -> Result<Value> {
evolution_operation("fitness", args, input, env, false, true)
}
fn evolution_operation(
name: &str,
args: Vec<Value>,
input: Option<Value>,
env: &mut Env,
multiple: bool,
evaluate_only: bool,
) -> Result<Value> {
let piped = input.is_some();
let value = input
.or_else(|| args.first().cloned())
.ok_or_else(|| crate::safety::arg_err(format!("{name} requires population")))?;
let idx = usize::from(!piped);
let lambda = need_lambda(
args.get(idx)
.ok_or_else(|| crate::safety::arg_err(format!("{name} requires fitness function")))?,
name,
)?
.clone();
if lambda.params.len() != 1 {
return Err(crate::safety::arg_err(format!(
"{name}: fitness function must take exactly one genome parameter"
)));
}
let generations = if evaluate_only {
None
} else if multiple {
let count = match args.get(idx + 1) {
None => 10,
Some(Value::Int(n)) => usize::try_from(*n)
.map_err(|_| crate::safety::arg_err("evolve: generations must be non-negative"))?,
Some(other) => {
return Err(crate::safety::bad_arg(
name,
"generations: Int",
other.type_name(),
))
}
};
Some(count)
} else {
Some(1)
};
match get_population_genome_type(&value)?.as_str() {
"nn" => {
let mut population = value_to_population_nn(&value)?;
run_population_operation(
name,
&mut population,
&lambda,
env,
generations,
nn_to_value,
validate_nn,
)?;
population_to_value(&population, "nn")
}
"vec" => {
let mut population = value_to_population_vec(&value)?;
run_population_operation(
name,
&mut population,
&lambda,
env,
generations,
|genome| {
Ok(Value::Array(
genome.iter().copied().map(Value::Float).collect(),
))
},
|genome| validate_vector_genome(genome),
)?;
population_to_value(&population, "vec")
}
other => Err(crate::safety::bad_arg(
name,
"population genome_type: nn or vec",
other,
)),
}
}
fn check_population_work<G: crate::evolution::Evolvable>(
name: &str,
pop: &Population<G>,
generations: Option<usize>,
) -> Result<()> {
let count = generations.unwrap_or(1);
let parameters: usize = pop
.individuals
.iter()
.map(|individual| individual.genome.param_count())
.sum();
if count > MAX_EVOLUTION_GENERATIONS
|| pop
.individuals
.len()
.checked_mul(count)
.is_none_or(|n| n > 100_000)
|| parameters.checked_mul(count).is_none_or(|n| n > 10_000_000)
|| pop
.stats
.fitness_history
.len()
.checked_add(count)
.is_none_or(|n| n > MAX_EVOLUTION_GENERATIONS)
{
return Err(crate::safety::budget_exceeded(
name,
"evolution work or history budget exceeded".into(),
"use fewer generations or a smaller population",
));
}
let final_generation = pop.generation.checked_add(generations.unwrap_or(0));
let final_id = pop
.next_id
.checked_add(pop.individuals.len() * generations.unwrap_or(0));
if final_generation.is_none_or(|n| i64::try_from(n).is_err())
|| final_id.is_none_or(|n| i64::try_from(n).is_err())
{
return Err(crate::safety::arg_err(
"evolution generation or individual ID overflow",
));
}
Ok(())
}
fn run_population_operation<G: crate::evolution::Evolvable>(
name: &str,
pop: &mut Population<G>,
lambda: &Lambda,
env: &mut Env,
generations: Option<usize>,
encode: impl Fn(&G) -> Result<Value>,
validate: impl Fn(&G) -> Result<()>,
) -> Result<()> {
check_population_work(name, pop, generations)?;
let count = generations.unwrap_or(1);
for _ in 0..count {
// Populate the cache fallibly before entering the infallible engine.
// A failed callback never becomes a zero score or triggers another call.
for individual in &mut pop.individuals {
if individual.fitness.is_none() {
let result = call_lambda(lambda, &[encode(&individual.genome)?], env)?;
individual.fitness = Some(FitnessResult::new(finite_nn_number(name, &result)?));
}
}
if generations.is_some() {
pop.evolve(|_| unreachable!("all parent fitness values were evaluated"));
} else {
pop.evaluate(|_| unreachable!("all fitness values were evaluated"));
pop.stats.generation = pop.generation;
}
if [
pop.stats.best_fitness,
pop.stats.worst_fitness,
pop.stats.avg_fitness,
pop.stats.diversity,
]
.iter()
.any(|n| !n.is_finite())
{
return Err(crate::safety::bad_state(
name,
"non-finite statistics",
"fitness arithmetic exceeded the numeric range",
));
}
for individual in &pop.individuals {
validate(&individual.genome).map_err(|_| {
crate::safety::bad_state(
name,
"invalid resulting genome",
"mutation exceeded the numeric range",
)
})?;
}
}
Ok(())
}
/// best_individual(population)
/// Get the best individual from population
fn bi_best_individual(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let pop_val = input
.or_else(|| args.first().cloned())
.ok_or_else(|| crate::safety::arg_err("best_individual requires population"))?;
let genome_type = get_population_genome_type(&pop_val)?;
match genome_type.as_str() {
"nn" => {
let pop = value_to_population_nn(&pop_val)?;
if pop
.individuals
.iter()
.any(|individual| individual.fitness.is_none())
{
return Err(crate::safety::bad_state(
"best_individual",
"population is not fully evaluated",
"call fitness before ranking individuals",
));
}
let best = pop
.individuals
.iter()
.max_by(|a, b| {
a.score()
.partial_cmp(&b.score())
.unwrap_or(std::cmp::Ordering::Equal)
})
.ok_or_else(|| crate::safety::arg_err("Empty population"))?;
let mut result = BTreeMap::new();
result.insert("genome".to_string(), nn_to_value(&best.genome)?);
result.insert("fitness".to_string(), Value::Float(best.score()));
result.insert("generation".to_string(), Value::Int(best.generation as i64));
result.insert("id".to_string(), Value::Int(best.id as i64));
Ok(Value::Record(result))
}
"vec" => {
let pop = value_to_population_vec(&pop_val)?;
if pop
.individuals
.iter()
.any(|individual| individual.fitness.is_none())
{
return Err(crate::safety::bad_state(
"best_individual",
"population is not fully evaluated",
"call fitness before ranking individuals",
));
}
let best = pop
.individuals
.iter()
.max_by(|a, b| {
a.score()
.partial_cmp(&b.score())
.unwrap_or(std::cmp::Ordering::Equal)
})
.ok_or_else(|| crate::safety::arg_err("Empty population"))?;
let mut result = BTreeMap::new();
result.insert(
"genome".to_string(),
Value::Array(best.genome.iter().map(|&f| Value::Float(f)).collect()),
);
result.insert("fitness".to_string(), Value::Float(best.score()));
result.insert("generation".to_string(), Value::Int(best.generation as i64));
result.insert("id".to_string(), Value::Int(best.id as i64));
Ok(Value::Record(result))
}
_ => Err(crate::safety::bad_arg(
"best_individual",
"population genome_type: nn or vec",
&genome_type,
)),
}
}
/// evolution_stats(population)
/// Get evolution statistics
fn bi_evolution_stats(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let pop_val = input
.or_else(|| args.first().cloned())
.ok_or_else(|| crate::safety::arg_err("evolution_stats requires population"))?;
if let Value::Record(rec) = &pop_val {
if let Some(Value::Record(stats)) = rec.get("stats") {
return Ok(Value::Record(stats.clone()));
}
}
Err(crate::safety::arg_err(
"Could not extract stats from population",
))
}
/// selection_strategy(name, [param])
/// Create selection strategy
/// Args:
/// - name: String - "tournament", "roulette", "rank", "truncation", "elite"
/// - param: Int/Float (optional) - Strategy parameter
fn bi_selection_strategy(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let name = args
.first()
.and_then(|v| {
if let Value::Str(s) = v {
Some(s.as_str())
} else {
None
}
})
.ok_or_else(|| crate::safety::arg_err("selection_strategy requires name"))?;
let strategy = match name.to_lowercase().as_str() {
"tournament" => {
let size = args
.get(1)
.map(|v| match v {
Value::Int(n) => Ok(*n),
other => Err(crate::safety::bad_arg(
"selection_strategy",
"parameter: Int",
other.type_name(),
)),
})
.transpose()?
.unwrap_or(3);
let size = usize::try_from(size)
.ok()
.filter(|&n| n > 0)
.ok_or_else(|| {
crate::safety::arg_err("selection_strategy: tournament size must be positive")
})?;
SelectionStrategy::Tournament(size)
}
"roulette" | "rank" if args.len() > 1 => {
return Err(crate::safety::arg_err(
"selection_strategy: roulette and rank take no parameter",
))
}
"roulette" => SelectionStrategy::Roulette,
"rank" => SelectionStrategy::Rank,
"truncation" => {
let ratio = args
.get(1)
.map(|v| finite_nn_number("selection_strategy", v))
.transpose()?
.unwrap_or(0.5);
if !(0.0..=1.0).contains(&ratio) {
return Err(crate::safety::arg_err(
"selection_strategy: truncation ratio must be in 0..=1",
));
}
SelectionStrategy::Truncation(ratio)
}
"elite" => {
let n = args
.get(1)
.map(|v| match v {
Value::Int(n) => Ok(*n),
other => Err(crate::safety::bad_arg(
"selection_strategy",
"parameter: Int",
other.type_name(),
)),
})
.transpose()?
.unwrap_or(5);
let n = usize::try_from(n).ok().filter(|&n| n > 0).ok_or_else(|| {
crate::safety::arg_err("selection_strategy: elite count must be positive")
})?;
SelectionStrategy::Elite(n)
}
_ => {
return Err(crate::safety::bad_arg(
"selection_strategy",
"one of: tournament, roulette, rank, truncation, elite",
name,
))
}
};
let mut result = BTreeMap::new();
result.insert(
"type".to_string(),
Value::Str("selection_strategy".to_string()),
);
result.insert("name".to_string(), Value::Str(format!("{:?}", strategy)));
Ok(Value::Record(result))
}
/// crossover_strategy(name, [param])
/// Create crossover strategy
fn bi_crossover_strategy(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let name = args
.first()
.and_then(|v| {
if let Value::Str(s) = v {
Some(s.as_str())
} else {
None
}
})
.ok_or_else(|| crate::safety::arg_err("crossover_strategy requires name"))?;
let strategy = match name.to_lowercase().as_str() {
"single_point" | "singlepoint" | "two_point" | "twopoint" if args.len() > 1 => {
return Err(crate::safety::arg_err(
"crossover_strategy: point crossover takes no parameter",
))
}
"single_point" | "singlepoint" => CrossoverStrategy::SinglePoint,
"two_point" | "twopoint" => CrossoverStrategy::TwoPoint,
"uniform" => {
let prob = args
.get(1)
.map(|v| finite_nn_number("crossover_strategy", v))
.transpose()?
.unwrap_or(0.5);
if !(0.0..=1.0).contains(&prob) {
return Err(crate::safety::arg_err(
"crossover_strategy: probability must be in 0..=1",
));
}
CrossoverStrategy::Uniform(prob)
}
"blend" => {
let alpha = args
.get(1)
.map(|v| finite_nn_number("crossover_strategy", v))
.transpose()?
.unwrap_or(0.5);
if alpha < 0.0 {
return Err(crate::safety::arg_err(
"crossover_strategy: alpha must be non-negative",
));
}
CrossoverStrategy::Blend(alpha)
}
"sbx" => {
let eta = args
.get(1)
.map(|v| finite_nn_number("crossover_strategy", v))
.transpose()?
.unwrap_or(20.0);
if eta <= 0.0 {
return Err(crate::safety::arg_err(
"crossover_strategy: eta must be positive",
));
}
CrossoverStrategy::SBX(eta)
}
_ => {
return Err(crate::safety::bad_arg(
"crossover_strategy",
"one of: single_point, two_point, uniform, blend, sbx",
name,
))
}
};
let mut result = BTreeMap::new();
result.insert(
"type".to_string(),
Value::Str("crossover_strategy".to_string()),
);
result.insert("name".to_string(), Value::Str(format!("{:?}", strategy)));
Ok(Value::Record(result))
}
const MAX_EVOLUTION_POPULATION: usize = 10_000;
const MAX_EVOLUTION_GENERATIONS: usize = 10_000;
fn parse_evolution_options(
builtin: &str,
options: Option<&Value>,
size: Option<usize>,
) -> Result<EvolutionConfig> {
let opts = match options {
None => None,
Some(Value::Record(opts)) => Some(opts),
Some(other) => {
return Err(crate::safety::bad_arg(
builtin,
"options: Record",
other.type_name(),
))
}
};
if let Some(opts) = opts {
for (key, value) in opts {
match key.as_str() {
"population_size" | "generations" | "mutation_rate" | "mutation_strength"
| "crossover_rate" | "elitism" => {}
"type" if matches!(value, Value::Str(tag) if tag == "evolution_config") => {}
_ => {
return Err(crate::safety::arg_err(format!(
"{builtin}: unknown option or invalid configuration marker {key}"
)))
}
}
}
}
let get = |key: &str| opts.and_then(|opts| opts.get(key));
let integer = |key: &str, default: usize, positive: bool| -> Result<usize> {
let Some(value) = get(key) else {
return Ok(default);
};
let n = match value {
Value::Int(n) => *n,
other => {
return Err(crate::safety::bad_arg(
builtin,
&format!("{key}: Int"),
other.type_name(),
))
}
};
usize::try_from(n)
.ok()
.filter(|n| !positive || *n > 0)
.ok_or_else(|| {
crate::safety::arg_err(format!(
"{builtin}: {key} must be a {}representable Int",
if positive {
"positive "
} else {
"non-negative "
}
))
})
};
let mut config = EvolutionConfig::default();
config.population_size = integer(
"population_size",
size.unwrap_or(config.population_size),
true,
)?;
if size.is_some_and(|size| size != config.population_size) {
return Err(crate::safety::arg_err(
"population: configuration population_size must match the size argument",
));
}
config.generations = integer("generations", config.generations, false)?;
if config.population_size > MAX_EVOLUTION_POPULATION
|| config.generations > MAX_EVOLUTION_GENERATIONS
{
return Err(crate::safety::budget_exceeded(
builtin,
"population_size and generations are limited to 10000".into(),
"use a smaller population or fewer generations",
));
}
config.elitism = integer("elitism", config.elitism.min(config.population_size), false)?;
if config.elitism > config.population_size {
return Err(crate::safety::arg_err(format!(
"{builtin}: elitism cannot exceed population_size"
)));
}
let number = |key: &str, default: f64| -> Result<f64> {
get(key)
.map(|value| finite_nn_number(builtin, value))
.transpose()
.map(|value| value.unwrap_or(default))
};
config.mutation_rate = number("mutation_rate", config.mutation_rate)?;
config.mutation_strength = number("mutation_strength", config.mutation_strength)?;
config.crossover_rate = number("crossover_rate", config.crossover_rate)?;
if !(0.0..=1.0).contains(&config.mutation_rate)
|| !(0.0..=1.0).contains(&config.crossover_rate)
|| config.mutation_strength < 0.0
{
return Err(crate::safety::arg_err(format!(
"{builtin}: rates must be in 0..=1 and mutation_strength non-negative"
)));
}
Ok(config)
}
/// evolution_config([options])
/// Create evolution configuration
fn bi_evolution_config(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let config = parse_evolution_options("evolution_config", args.first(), None)?;
let mut result = BTreeMap::new();
result.insert(
"type".to_string(),
Value::Str("evolution_config".to_string()),
);
result.insert(
"population_size".to_string(),
Value::Int(config.population_size as i64),
);
result.insert(
"generations".to_string(),
Value::Int(config.generations as i64),
);
result.insert(
"mutation_rate".to_string(),
Value::Float(config.mutation_rate),
);
result.insert(
"mutation_strength".to_string(),
Value::Float(config.mutation_strength),
);
result.insert(
"crossover_rate".to_string(),
Value::Float(config.crossover_rate),
);
result.insert("elitism".to_string(), Value::Int(config.elitism as i64));
Ok(Value::Record(result))
}
/// Decoded population variants retained during batch preflight.
enum EvolutionPopulation {
Neural(Population<NeuralNetwork>),
Vector(Population<Vec<f64>>),
}
/// Evolve populations independently after validating the complete batch.
fn bi_coevolve(args: Vec<Value>, input: Option<Value>, env: &mut Env) -> Result<Value> {
let piped = input.is_some();
let value = input
.or_else(|| args.first().cloned())
.ok_or_else(|| crate::safety::arg_err("coevolve requires populations array"))?;
let values = expect_array("coevolve", &value)?;
let idx = usize::from(!piped);
let lambda = need_lambda(
args.get(idx)
.ok_or_else(|| crate::safety::arg_err("coevolve requires fitness function"))?,
"coevolve",
)?
.clone();
if lambda.params.len() != 1 {
return Err(crate::safety::arg_err(
"coevolve: fitness function must take exactly one genome parameter",
));
}
let generations = match args.get(idx + 1) {
None => 10,
Some(Value::Int(n)) => usize::try_from(*n)
.map_err(|_| crate::safety::arg_err("coevolve: generations must be non-negative"))?,
Some(other) => {
return Err(crate::safety::bad_arg(
"coevolve",
"generations: Int",
other.type_name(),
))
}
};
let budget = || {
crate::safety::budget_exceeded(
"coevolve",
"combined population, encoding, history or work budget exceeded".into(),
"use fewer or smaller populations and fewer generations",
)
};
if values.len() > MAX_EVOLUTION_POPULATION || generations > MAX_EVOLUTION_GENERATIONS {
return Err(budget());
}
let mut bytes = 0usize;
for value in values {
if let Value::Record(record) = value {
for key in ["_individuals", "_config"] {
if let Some(Value::Str(data)) = record.get(key) {
bytes = bytes
.checked_add(data.len())
.filter(|n| *n <= 64 * 1024 * 1024)
.ok_or_else(&budget)?;
}
}
}
}
// Decode and preflight the complete batch before invoking any callback.
let mut populations = Vec::with_capacity(values.len());
let mut individuals = 0usize;
let mut parameters = 0usize;
let mut history = 0usize;
for value in values {
let (population, size, params, entries) = match get_population_genome_type(value)?.as_str()
{
"nn" => {
let pop = value_to_population_nn(value)?;
check_population_work("coevolve", &pop, Some(generations))?;
let params = pop
.individuals
.iter()
.map(|individual| individual.genome.param_count())
.sum::<usize>();
let size = pop.individuals.len();
let entries = pop.stats.fitness_history.len();
(EvolutionPopulation::Neural(pop), size, params, entries)
}
"vec" => {
let pop = value_to_population_vec(value)?;
check_population_work("coevolve", &pop, Some(generations))?;
let params = pop
.individuals
.iter()
.map(|individual| individual.genome.len())
.sum::<usize>();
let size = pop.individuals.len();
let entries = pop.stats.fitness_history.len();
(EvolutionPopulation::Vector(pop), size, params, entries)
}
other => {
return Err(crate::safety::bad_arg(
"coevolve",
"population genome_type: nn or vec",
other,
))
}
};
individuals = individuals
.checked_add(size)
.filter(|n| *n <= MAX_EVOLUTION_POPULATION)
.ok_or_else(&budget)?;
parameters = parameters
.checked_add(params)
.filter(|n| *n <= MAX_NN_PARAMETERS)
.ok_or_else(&budget)?;
history = history
.checked_add(entries)
.filter(|n| *n <= MAX_EVOLUTION_GENERATIONS)
.ok_or_else(&budget)?;
populations.push(population);
}
if individuals
.checked_mul(generations)
.is_none_or(|n| n > 100_000)
|| parameters
.checked_mul(generations)
.is_none_or(|n| n > 10_000_000)
|| populations
.len()
.checked_mul(generations)
.and_then(|n| history.checked_add(n))
.is_none_or(|n| n > MAX_EVOLUTION_GENERATIONS)
{
return Err(budget());
}
let mut results = Vec::with_capacity(populations.len());
for population in populations {
match population {
EvolutionPopulation::Neural(mut pop) => {
run_population_operation(
"coevolve",
&mut pop,
&lambda,
env,
Some(generations),
nn_to_value,
validate_nn,
)?;
results.push(population_to_value(&pop, "nn")?);
}
EvolutionPopulation::Vector(mut pop) => {
run_population_operation(
"coevolve",
&mut pop,
&lambda,
env,
Some(generations),
|genome| {
Ok(Value::Array(
genome.iter().copied().map(Value::Float).collect(),
))
},
|genome| validate_vector_genome(genome),
)?;
results.push(population_to_value(&pop, "vec")?);
}
}
}
Ok(Value::Array(results))
}
// --------------- Neural/Evolution Helpers ---------------
const MAX_NN_PARAMETERS: usize = 1_000_000;
fn finite_nn_number(builtin: &str, value: &Value) -> Result<f64> {
let number = match value {
Value::Int(n) => *n as f64,
Value::Float(n) => *n,
other => {
return Err(crate::safety::bad_arg(
builtin,
"finite Number",
other.type_name(),
))
}
};
if !number.is_finite() {
return Err(crate::safety::arg_err(&format!(
"{builtin}: number must be finite"
)));
}
Ok(number)
}
fn checked_nn_parameter_count(total: usize, input: usize, output: usize) -> Result<usize> {
total
.checked_add(
input
.checked_mul(output)
.and_then(|n| n.checked_add(output))
.ok_or_else(|| {
crate::safety::arg_err("neural network: parameter count overflow")
})?,
)
.filter(|&n| n <= MAX_NN_PARAMETERS)
.ok_or_else(|| {
budget_unnamed(
"neural network: exceeds 1000000 parameters",
"reduce the number of layers or their sizes; the limit is 1000000 parameters",
)
})
}
fn validate_nn(network: &NeuralNetwork) -> Result<()> {
if network.layers.is_empty() {
return Err(crate::safety::arg_err(
"neural network: at least one dense layer is required",
));
}
let mut total = 0;
let mut previous_output = None;
for layer in &network.layers {
total = checked_nn_parameter_count(total, layer.input_size, layer.output_size)?;
if layer.input_size == 0
|| layer.output_size == 0
|| previous_output.is_some_and(|n| n != layer.input_size)
|| layer.weights.len() != layer.output_size
|| layer.biases.len() != layer.output_size
|| layer
.weights
.iter()
.any(|row| row.len() != layer.input_size || row.iter().any(|n| !n.is_finite()))
|| layer.biases.iter().any(|n| !n.is_finite())
|| matches!(layer.activation, Activation::LeakyReLU(alpha) if !alpha.is_finite())
{
return Err(crate::safety::arg_err(
"neural network: inconsistent layer dimensions or non-finite parameters",
));
}
previous_output = Some(layer.output_size);
}
Ok(())
}
fn value_to_f64(v: &Value) -> Result<f64> {
match v {
Value::Float(f) => Ok(*f),
Value::Int(i) => Ok(*i as f64),
_ => Err(crate::safety::arg_err(format!(
"Expected numeric value, got {:?}",
v
))),
}
}
fn nn_to_value(network: &NeuralNetwork) -> Result<Value> {
let json = serde_json::to_string(network).context("Failed to serialize network")?;
let mut rec = BTreeMap::new();
rec.insert("type".to_string(), Value::Str("neural_network".to_string()));
rec.insert("name".to_string(), Value::Str(network.name.clone()));
rec.insert(
"layer_count".to_string(),
Value::Int(network.layers.len() as i64),
);
rec.insert(
"param_count".to_string(),
Value::Int(network.param_count() as i64),
);
rec.insert("_data".to_string(), Value::Str(json));
Ok(Value::Record(rec))
}
fn value_to_nn(v: &Value) -> Result<NeuralNetwork> {
if let Value::Record(rec) = v {
if let Some(Value::Str(data)) = rec.get("_data") {
if data.len() > 64 * 1024 * 1024 {
return Err(budget_unnamed(
"neural network: encoded data exceeds 64 MiB",
"the encoded network is larger than 64 MiB and cannot be decoded",
));
}
let network = serde_json::from_str(data).map_err(|e| {
crate::safety::arg_err(&format!("neural network: invalid encoded data: {e}"))
})?;
validate_nn(&network)?;
return Ok(network);
}
}
Err(crate::safety::arg_err("Invalid neural network value"))
}
fn consensus_net_to_value(network: &ConsensusNetwork) -> Result<Value> {
let json = serde_json::to_string(network).context("Failed to serialize consensus network")?;
let mut rec = BTreeMap::new();
rec.insert(
"type".to_string(),
Value::Str("consensus_network".to_string()),
);
rec.insert("name".to_string(), Value::Str(network.name.clone()));
rec.insert(
"agent_count".to_string(),
Value::Int(network.agent_networks.len() as i64),
);
rec.insert("_data".to_string(), Value::Str(json));
Ok(Value::Record(rec))
}
fn value_to_consensus_net(v: &Value) -> Result<ConsensusNetwork> {
if let Value::Record(rec) = v {
if let Some(Value::Str(data)) = rec.get("_data") {
if data.len() > 64 * 1024 * 1024 {
return Err(crate::safety::budget_exceeded(
"consensus_vote",
"consensus network encoding exceeds 64 MiB".into(),
"use a smaller network",
));
}
let network: ConsensusNetwork = serde_json::from_str(data).map_err(|e| {
crate::safety::bad_arg(
"consensus_vote",
"valid encoded consensus network",
&e.to_string(),
)
})?;
validate_consensus_net(&network)?;
return Ok(network);
}
}
Err(crate::safety::arg_err("Invalid consensus network value"))
}
fn validate_consensus_net(network: &ConsensusNetwork) -> Result<()> {
if network.state_dim == 0 || network.message_dim == 0 || network.agent_networks.is_empty() {
return Err(crate::safety::arg_err(
"consensus network dimensions and agent count must be positive",
));
}
if network.agent_networks.len() > 1024 || network.num_rounds > 16 {
return Err(crate::safety::budget_exceeded(
"consensus_vote",
"consensus network exceeds 1024 agents or 16 rounds".into(),
"use fewer agents or rounds",
));
}
let mut total = 0;
for nn in [
&network.local_encoder,
&network.message_processor,
&network.decision_network,
]
.into_iter()
.chain(network.agent_networks.iter())
{
validate_nn(nn)?;
for layer in &nn.layers {
total = checked_nn_parameter_count(total, layer.input_size, layer.output_size)?;
}
}
let dimensions_match = |nn: &NeuralNetwork, input: Option<usize>, output: usize| {
input == Some(nn.layers[0].input_size) && nn.layers.last().unwrap().output_size == output
};
if !dimensions_match(
&network.local_encoder,
Some(network.state_dim),
network.message_dim,
) || !dimensions_match(
&network.message_processor,
network.message_dim.checked_mul(2),
network.message_dim,
) || Some(network.decision_network.layers[0].input_size)
!= network.message_dim.checked_add(network.state_dim)
|| network
.agent_networks
.iter()
.any(|nn| !dimensions_match(nn, Some(network.state_dim), network.message_dim))
{
return Err(crate::safety::arg_err(
"consensus network component dimensions do not match",
));
}
Ok(())
}
fn get_population_genome_type(v: &Value) -> Result<String> {
if let Value::Record(rec) = v {
if let Some(Value::Str(gt)) = rec.get("genome_type") {
return Ok(gt.clone());
}
}
Err(crate::safety::arg_err(
"Could not determine genome type from population",
))
}
fn population_to_value<G: crate::evolution::Evolvable + serde::Serialize>(
pop: &Population<G>,
genome_type: &str,
) -> Result<Value> {
let json = serde_json::to_string(&pop.individuals).context("Failed to serialize population")?;
let config_json = serde_json::to_string(&pop.config).context("Failed to serialize config")?;
let mut stats = BTreeMap::new();
stats.insert(
"generation".to_string(),
Value::Int(pop.stats.generation as i64),
);
stats.insert(
"best_fitness".to_string(),
Value::Float(pop.stats.best_fitness),
);
stats.insert(
"avg_fitness".to_string(),
Value::Float(pop.stats.avg_fitness),
);
stats.insert(
"worst_fitness".to_string(),
Value::Float(pop.stats.worst_fitness),
);
stats.insert("diversity".to_string(), Value::Float(pop.stats.diversity));
stats.insert(
"history".to_string(),
Value::Array(
pop.stats
.fitness_history
.iter()
.map(|&f| Value::Float(f))
.collect(),
),
);
let mut rec = BTreeMap::new();
rec.insert("type".to_string(), Value::Str("population".to_string()));
rec.insert(
"genome_type".to_string(),
Value::Str(genome_type.to_string()),
);
rec.insert("size".to_string(), Value::Int(pop.individuals.len() as i64));
rec.insert("generation".to_string(), Value::Int(pop.generation as i64));
rec.insert("next_id".to_string(), Value::Int(pop.next_id as i64));
rec.insert("stats".to_string(), Value::Record(stats));
rec.insert("_individuals".to_string(), Value::Str(json));
rec.insert("_config".to_string(), Value::Str(config_json));
Ok(Value::Record(rec))
}
fn validate_vector_genome(genome: &[f64]) -> Result<()> {
if genome.is_empty() || genome.iter().any(|n| !n.is_finite()) {
return Err(crate::safety::arg_err(
"population vectors must be nonempty and finite",
));
}
Ok(())
}
fn value_to_population_nn(value: &Value) -> Result<Population<NeuralNetwork>> {
let pop = decode_population(value, validate_nn)?;
let dimensions: Vec<_> = pop.individuals[0]
.genome
.layers
.iter()
.map(|layer| (layer.input_size, layer.output_size))
.collect();
if pop.individuals.iter().any(|individual| {
individual
.genome
.layers
.iter()
.map(|layer| (layer.input_size, layer.output_size))
.collect::<Vec<_>>()
!= dimensions
}) {
return Err(crate::safety::arg_err(
"population networks must have matching layer dimensions",
));
}
Ok(pop)
}
fn value_to_population_vec(value: &Value) -> Result<Population<Vec<f64>>> {
let pop = decode_population(value, |genome: &Vec<f64>| validate_vector_genome(genome))?;
let width = pop.individuals[0].genome.len();
if pop
.individuals
.iter()
.any(|individual| individual.genome.len() != width)
{
return Err(crate::safety::arg_err(
"population vectors must have equal lengths",
));
}
Ok(pop)
}
fn decode_population<G: crate::evolution::Evolvable + serde::de::DeserializeOwned>(
value: &Value,
validate: impl Fn(&G) -> Result<()>,
) -> Result<Population<G>> {
let Value::Record(record) = value else {
return Err(crate::safety::arg_err("population must be a Record"));
};
let text = |key: &str| -> Result<&str> {
match record.get(key) {
Some(Value::Str(text)) => Ok(text),
_ => Err(crate::safety::arg_err(format!(
"population requires encoded {key}: String"
))),
}
};
let data = text("_individuals")?;
let config_data = text("_config")?;
if data.len().saturating_add(config_data.len()) > 64 * 1024 * 1024 {
return Err(crate::safety::budget_exceeded(
"evo",
"encoded population exceeds 64 MiB".into(),
"use a smaller population",
));
}
let individuals: Vec<crate::evolution::Individual<G>> = serde_json::from_str(data)
.map_err(|e| crate::safety::bad_arg("evo", "valid encoded individuals", &e.to_string()))?;
let config: EvolutionConfig = serde_json::from_str(config_data).map_err(|e| {
crate::safety::bad_arg("evo", "valid encoded configuration", &e.to_string())
})?;
if individuals.is_empty() || individuals.len() != config.population_size {
return Err(crate::safety::arg_err(
"population must be nonempty and match configured population_size",
));
}
let bounded_int = |n: usize| {
i64::try_from(n)
.map(Value::Int)
.map_err(|_| crate::safety::arg_err("population count exceeds Int range"))
};
let options = Value::Record(BTreeMap::from([
(
"population_size".into(),
bounded_int(config.population_size)?,
),
("generations".into(), bounded_int(config.generations)?),
("elitism".into(), bounded_int(config.elitism)?),
("mutation_rate".into(), Value::Float(config.mutation_rate)),
(
"mutation_strength".into(),
Value::Float(config.mutation_strength),
),
("crossover_rate".into(), Value::Float(config.crossover_rate)),
]));
parse_evolution_options("evo", Some(&options), None)?;
if config.selection != SelectionStrategy::Tournament(3)
|| config.crossover != CrossoverStrategy::Uniform(0.5)
|| config.seed.is_some()
{
return Err(crate::safety::arg_err(
"population uses unsupported selection, crossover or seed settings",
));
}
let nonnegative = |value: Option<&Value>| -> Result<usize> {
match value {
Some(Value::Int(n)) => usize::try_from(*n).map_err(|_| {
crate::safety::arg_err("population counters must be non-negative Int values")
}),
_ => Err(crate::safety::arg_err(
"population counters must be Int values",
)),
}
};
let generation = nonnegative(record.get("generation"))?;
let mut ids = std::collections::HashSet::new();
let mut parameters = 0usize;
let mut next_id = 0usize;
for individual in &individuals {
validate(&individual.genome)?;
parameters = parameters
.checked_add(individual.genome.param_count())
.filter(|n| *n <= MAX_NN_PARAMETERS)
.ok_or_else(|| {
crate::safety::budget_exceeded(
"evo",
"population exceeds one million genome parameters".into(),
"use a smaller population",
)
})?;
if individual.generation > generation
|| i64::try_from(individual.id).is_err()
|| !ids.insert(individual.id)
{
return Err(crate::safety::arg_err(
"population individual counters or IDs are invalid",
));
}
next_id = next_id.max(
individual
.id
.checked_add(1)
.ok_or_else(|| crate::safety::arg_err("population individual ID overflow"))?,
);
if individual.fitness.as_ref().is_some_and(|fitness| {
!fitness.score.is_finite() || fitness.metrics.values().any(|n| !n.is_finite())
}) {
return Err(crate::safety::arg_err(
"population fitness values must be finite",
));
}
}
if let Some(value) = record.get("next_id") {
let supplied = nonnegative(Some(value))?;
if supplied < next_id {
return Err(crate::safety::arg_err(
"population next_id would reuse an individual ID",
));
}
next_id = supplied;
}
bounded_int(next_id)?;
let Some(Value::Record(stats)) = record.get("stats") else {
return Err(crate::safety::arg_err("population requires stats: Record"));
};
let stats_generation = nonnegative(stats.get("generation"))?;
if stats_generation > generation {
return Err(crate::safety::arg_err(
"population stats generation exceeds population generation",
));
}
let Some(Value::Array(history)) = stats.get("history") else {
return Err(crate::safety::arg_err(
"population stats requires history: Array",
));
};
if history.len() > MAX_EVOLUTION_GENERATIONS {
return Err(crate::safety::budget_exceeded(
"evo",
"population history exceeds 10000 entries".into(),
"use a shorter history",
));
}
let history = history
.iter()
.map(|value| finite_nn_number("evo", value))
.collect::<Result<Vec<_>>>()?;
let number = |key: &str| -> Result<f64> {
match stats.get(key) {
Some(Value::Float(n)) => Ok(*n),
Some(Value::Int(n)) => Ok(*n as f64),
_ => Err(crate::safety::arg_err(format!(
"population stats requires numeric {key}"
))),
}
};
let best = number("best_fitness")?;
let worst = number("worst_fitness")?;
let avg = number("avg_fitness")?;
let diversity = number("diversity")?;
let initial = history.is_empty()
&& individuals
.iter()
.all(|individual| individual.fitness.is_none())
&& best == f64::NEG_INFINITY
&& worst == f64::INFINITY;
if (!initial && (!best.is_finite() || !worst.is_finite()))
|| !avg.is_finite()
|| !diversity.is_finite()
|| diversity < 0.0
{
return Err(crate::safety::arg_err(
"population statistics must be finite except unevaluated initial extrema",
));
}
Ok(Population {
individuals,
config,
generation,
next_id,
stats: crate::evolution::EvolutionStats {
generation: stats_generation,
best_fitness: best,
worst_fitness: worst,
avg_fitness: avg,
diversity,
fitness_history: history,
},
})
}
// ==================== Reinforcement Learning Builtins ====================
/// Create a Q-learning agent
/// Usage: rl_agent(name, state_size, action_size, [config])
const MAX_RL_CELLS: usize = 1_000_000;
fn rl_positive_size(name: &str, value: &Value) -> Result<usize> {
match value {
Value::Int(n) => usize::try_from(*n).ok().filter(|n| *n > 0).ok_or_else(|| {
crate::safety::arg_err(format!("{name}: size must be a positive representable Int"))
}),
other => Err(crate::safety::bad_arg(
name,
"positive Int",
other.type_name(),
)),
}
}
fn validate_q_agent(agent: &QLearningAgent) -> Result<()> {
if agent.state_size == 0 || agent.action_size == 0 {
return Err(crate::safety::arg_err(
"Q-learning dimensions must be positive",
));
}
if agent.action_size > 10000
|| agent
.state_size
.checked_mul(agent.action_size)
.is_none_or(|n| n > MAX_RL_CELLS)
|| agent.q_table.len() > MAX_RL_CELLS
{
return Err(crate::safety::budget_exceeded(
"rl",
"Q-learning exceeds 10000 actions or one million state-action cells".into(),
"use a smaller state/action space",
));
}
if [
agent.learning_rate,
agent.discount,
agent.epsilon,
agent.epsilon_decay,
agent.epsilon_min,
]
.iter()
.any(|n| !n.is_finite() || !(0.0..=1.0).contains(n))
|| i64::try_from(agent.steps).is_err()
{
return Err(crate::safety::arg_err(
"Q-learning rates must be finite probabilities and steps must fit Int",
));
}
if agent.q_table.iter().any(|(&(state, action), q)| {
state >= agent.state_size || action >= agent.action_size || !q.is_finite()
}) {
return Err(crate::safety::arg_err(
"Q-learning table indices must be in range and values finite",
));
}
Ok(())
}
fn validate_sarsa_agent(agent: &SarsaAgent) -> Result<()> {
if agent.state_size == 0 || agent.action_size == 0 {
return Err(crate::safety::arg_err("SARSA dimensions must be positive"));
}
if agent.action_size > 10000
|| agent
.state_size
.checked_mul(agent.action_size)
.is_none_or(|n| n > MAX_RL_CELLS)
|| agent.q_table.len() > MAX_RL_CELLS
{
return Err(crate::safety::budget_exceeded(
"rl",
"SARSA exceeds 10000 actions or one million state-action cells".into(),
"use a smaller state/action space",
));
}
if [agent.learning_rate, agent.discount, agent.epsilon]
.iter()
.any(|n| !n.is_finite() || !(0.0..=1.0).contains(n))
|| i64::try_from(agent.steps).is_err()
{
return Err(crate::safety::arg_err(
"SARSA rates must be finite probabilities and steps must fit Int",
));
}
if agent.q_table.iter().any(|(&(state, action), q)| {
state >= agent.state_size || action >= agent.action_size || !q.is_finite()
}) {
return Err(crate::safety::arg_err(
"SARSA table indices must be in range and values finite",
));
}
Ok(())
}
fn rl_index(name: &str, value: &Value, limit: usize) -> Result<usize> {
match value {
Value::Int(n) => usize::try_from(*n)
.ok()
.filter(|n| *n < limit)
.ok_or_else(|| crate::safety::arg_err(format!("{name}: index must be in 0..{limit}"))),
other => Err(crate::safety::bad_arg(
name,
"index: Int",
other.type_name(),
)),
}
}
fn bi_rl_agent(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let name = expect_string(
"rl_agent",
args.first()
.ok_or_else(|| crate::safety::arg_err("rl_agent requires name"))?,
)?;
let states = rl_positive_size(
"rl_agent",
args.get(1)
.ok_or_else(|| crate::safety::arg_err("rl_agent requires state_size"))?,
)?;
let actions = rl_positive_size(
"rl_agent",
args.get(2)
.ok_or_else(|| crate::safety::arg_err("rl_agent requires action_size"))?,
)?;
let mut agent = QLearningAgent::new(name, states, actions, 0.1, 0.99, 0.1);
if let Some(value) = args.get(3) {
let Value::Record(options) = value else {
return Err(crate::safety::bad_arg(
"rl_agent",
"config: Record",
value.type_name(),
));
};
for (key, value) in options {
let number = finite_nn_number("rl_agent", value)?;
match key.as_str() {
"learning_rate" => agent.learning_rate = number,
"discount" => agent.discount = number,
"epsilon" => agent.epsilon = number,
_ => {
return Err(crate::safety::arg_err(format!(
"rl_agent: unknown option {key}"
)))
}
}
}
}
validate_q_agent(&agent)?;
q_agent_to_value(&agent)
}
fn bi_rl_action(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let piped = input.is_some();
let value = input
.or_else(|| args.first().cloned())
.ok_or_else(|| crate::safety::arg_err("rl_action requires agent"))?;
let agent = value_to_q_agent(&value)?;
let idx = usize::from(!piped);
let state = rl_index(
"rl_action state",
args.get(idx)
.ok_or_else(|| crate::safety::arg_err("rl_action requires state"))?,
agent.state_size,
)?;
Ok(Value::Int(agent.select_action(state) as i64))
}
fn bi_rl_update(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let piped = input.is_some();
let value = input
.or_else(|| args.first().cloned())
.ok_or_else(|| crate::safety::arg_err("rl_update requires agent"))?;
let mut agent = value_to_q_agent(&value)?;
let idx = usize::from(!piped);
let arg = |offset| {
args.get(idx + offset).ok_or_else(|| {
crate::safety::arg_err("rl_update requires state, action, reward, next_state, done")
})
};
let state = rl_index("rl_update state", arg(0)?, agent.state_size)?;
let action = rl_index("rl_update action", arg(1)?, agent.action_size)?;
let reward = finite_nn_number("rl_update reward", arg(2)?)?;
let next_state = rl_index("rl_update next_state", arg(3)?, agent.state_size)?;
let Value::Bool(done) = arg(4)? else {
return Err(crate::safety::arg_err("rl_update done must be Bool"));
};
if agent
.steps
.checked_add(1)
.is_none_or(|n| i64::try_from(n).is_err())
{
return Err(crate::safety::arg_err("rl_update step counter overflow"));
}
agent.update(state, action, reward, next_state, *done);
validate_q_agent(&agent).map_err(|_| {
crate::safety::bad_state(
"rl_update",
"non-finite Q-learning result",
"the update exceeded the numeric range",
)
})?;
q_agent_to_value(&agent)
}
/// Create a SARSA agent with fixed default rates.
fn bi_rl_sarsa_agent(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let name = expect_string(
"rl_sarsa_agent",
args.first()
.ok_or_else(|| crate::safety::arg_err("rl_sarsa_agent requires name"))?,
)?;
let states = rl_positive_size(
"rl_sarsa_agent",
args.get(1)
.ok_or_else(|| crate::safety::arg_err("rl_sarsa_agent requires state_size"))?,
)?;
let actions = rl_positive_size(
"rl_sarsa_agent",
args.get(2)
.ok_or_else(|| crate::safety::arg_err("rl_sarsa_agent requires action_size"))?,
)?;
sarsa_agent_to_value(&SarsaAgent::new(&name, states, actions, 0.1, 0.99, 0.1))
}
/// Update a validated SARSA agent copy using the supplied next action.
fn bi_rl_sarsa_update(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let piped = input.is_some();
let value = input
.or_else(|| args.first().cloned())
.ok_or_else(|| crate::safety::arg_err("rl_sarsa_update requires agent"))?;
let mut agent = value_to_sarsa_agent(&value)?;
let idx = usize::from(!piped);
let arg = |offset| {
args.get(idx + offset).ok_or_else(|| {
crate::safety::arg_err(
"rl_sarsa_update requires state, action, reward, next_state, next_action, done",
)
})
};
let state = rl_index("rl_sarsa_update state", arg(0)?, agent.state_size)?;
let action = rl_index("rl_sarsa_update action", arg(1)?, agent.action_size)?;
let reward = finite_nn_number("rl_sarsa_update reward", arg(2)?)?;
let next_state = rl_index("rl_sarsa_update next_state", arg(3)?, agent.state_size)?;
let next_action = rl_index("rl_sarsa_update next_action", arg(4)?, agent.action_size)?;
let Value::Bool(done) = arg(5)? else {
return Err(crate::safety::arg_err("rl_sarsa_update done must be Bool"));
};
if agent
.steps
.checked_add(1)
.is_none_or(|n| i64::try_from(n).is_err())
{
return Err(crate::safety::arg_err(
"rl_sarsa_update step counter overflow",
));
}
agent.update(state, action, reward, next_state, next_action, *done);
validate_sarsa_agent(&agent).map_err(|_| {
crate::safety::bad_state(
"rl_sarsa_update",
"non-finite SARSA result",
"the update exceeded the numeric range",
)
})?;
sarsa_agent_to_value(&agent)
}
fn pg_dimensions(state_dim: usize, action_size: usize) -> Result<usize> {
if state_dim == 0 || action_size == 0 {
return Err(crate::safety::arg_err(
"policy-gradient dimensions must be positive",
));
}
state_dim
.checked_mul(action_size)
.filter(|n| *n <= MAX_RL_CELLS && action_size <= 10000)
.ok_or_else(|| {
crate::safety::budget_exceeded(
"rl_pg",
"policy exceeds 10000 actions or one million weights".into(),
"use a smaller policy",
)
})
}
fn pg_history_budget(state_dim: usize, steps: usize) -> Result<()> {
if steps > 10000
|| state_dim
.checked_mul(steps)
.is_none_or(|n| n > MAX_RL_CELLS)
{
return Err(crate::safety::budget_exceeded(
"rl_pg",
"episode exceeds 10000 steps or one million stored state values".into(),
"finish a shorter episode",
));
}
Ok(())
}
fn validate_pg_agent(agent: &PolicyGradientAgent) -> Result<()> {
pg_dimensions(agent.state_dim, agent.action_size)?;
pg_history_budget(agent.state_dim, agent.episode_states.len())?;
if agent.weights.len() != agent.action_size
|| agent
.weights
.iter()
.any(|row| row.len() != agent.state_dim || row.iter().any(|v| !v.is_finite()))
{
return Err(crate::safety::arg_err(
"policy weights must match dimensions and be finite",
));
}
if [agent.learning_rate, agent.discount]
.iter()
.any(|v| !v.is_finite() || !(0.0..=1.0).contains(v))
|| i64::try_from(agent.episodes).is_err()
{
return Err(crate::safety::arg_err(
"policy rates must be finite probabilities and episodes must fit Int",
));
}
if agent.episode_actions.len() != agent.episode_states.len()
|| agent.episode_rewards.len() != agent.episode_states.len()
|| agent
.episode_states
.iter()
.any(|row| row.len() != agent.state_dim || row.iter().any(|v| !v.is_finite()))
|| agent
.episode_actions
.iter()
.any(|a| *a >= agent.action_size)
|| agent.episode_rewards.iter().any(|v| !v.is_finite())
{
return Err(crate::safety::arg_err("episode states, actions and rewards must have equal lengths, valid shapes/indices and finite values"));
}
Ok(())
}
/// Create a linear softmax policy with fixed default rates.
fn bi_rl_pg_agent(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let name = expect_string(
"rl_pg_agent",
args.first()
.ok_or_else(|| crate::safety::arg_err("rl_pg_agent requires name"))?,
)?;
let state_dim = rl_positive_size(
"rl_pg_agent",
args.get(1)
.ok_or_else(|| crate::safety::arg_err("rl_pg_agent requires state_dim"))?,
)?;
let action_size = rl_positive_size(
"rl_pg_agent",
args.get(2)
.ok_or_else(|| crate::safety::arg_err("rl_pg_agent requires action_size"))?,
)?;
pg_dimensions(state_dim, action_size)?;
pg_agent_to_value(&PolicyGradientAgent::new(
&name,
state_dim,
action_size,
0.01,
0.99,
))
}
/// Append a validated step without updating policy weights.
fn bi_rl_pg_step(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let piped = input.is_some();
let value = input
.or_else(|| args.first().cloned())
.ok_or_else(|| crate::safety::arg_err("rl_pg_step requires agent"))?;
let mut agent = value_to_pg_agent(&value)?;
let idx = usize::from(!piped);
let arg = |offset| {
args.get(idx + offset)
.ok_or_else(|| crate::safety::arg_err("rl_pg_step requires state, action, reward"))
};
let Value::Array(values) = arg(0)? else {
return Err(crate::safety::arg_err("rl_pg_step state must be Array"));
};
if values.len() != agent.state_dim {
return Err(crate::safety::arg_err(
"rl_pg_step state must match state_dim",
));
}
pg_history_budget(agent.state_dim, agent.episode_states.len() + 1)?;
let state = values
.iter()
.map(|v| finite_nn_number("rl_pg_step state", v))
.collect::<Result<Vec<_>>>()?;
let action = rl_index("rl_pg_step action", arg(1)?, agent.action_size)?;
let reward = finite_nn_number("rl_pg_step reward", arg(2)?)?;
agent.record_step(state, action, reward);
pg_agent_to_value(&agent)
}
/// Finish a nonempty episode using REINFORCE, preserving empty episodes.
fn bi_rl_pg_episode_end(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let value = input
.or_else(|| args.first().cloned())
.ok_or_else(|| crate::safety::arg_err("rl_pg_episode_end requires agent"))?;
let mut agent = value_to_pg_agent(&value)?;
if !agent.episode_states.is_empty() {
if agent
.episodes
.checked_add(1)
.is_none_or(|n| i64::try_from(n).is_err())
{
return Err(crate::safety::arg_err(
"rl_pg_episode_end episode counter overflow",
));
}
let weights = pg_dimensions(agent.state_dim, agent.action_size)?;
if weights
.checked_mul(agent.episode_states.len())
.is_none_or(|n| n > 10_000_000)
{
return Err(crate::safety::budget_exceeded(
"rl_pg_episode_end",
"episode exceeds ten million weight-step pairs".into(),
"use a smaller policy or shorter episode",
));
}
agent.end_episode();
validate_pg_agent(&agent).map_err(|_| {
crate::safety::bad_state(
"rl_pg_episode_end",
"non-finite policy-gradient result",
"the update exceeded the numeric range",
)
})?;
}
pg_agent_to_value(&agent)
}
fn ac_dimensions(state_dim: usize, actions: usize) -> Result<()> {
pg_dimensions(state_dim, actions)?;
if actions
.checked_add(1)
.and_then(|n| n.checked_mul(state_dim))
.is_none_or(|n| n > MAX_RL_CELLS)
{
return Err(crate::safety::budget_exceeded(
"rl_ac",
"actor and critic exceed one million weights".into(),
"use a smaller agent",
));
}
Ok(())
}
fn validate_ac_agent(agent: &ActorCriticAgent) -> Result<()> {
ac_dimensions(agent.state_dim, agent.action_size)?;
if agent.actor_weights.len() != agent.action_size
|| agent
.actor_weights
.iter()
.any(|row| row.len() != agent.state_dim || row.iter().any(|n| !n.is_finite()))
|| agent.critic_weights.len() != agent.state_dim
|| agent.critic_weights.iter().any(|n| !n.is_finite())
{
return Err(crate::safety::arg_err(
"actor/critic weights must match dimensions and be finite",
));
}
if [agent.actor_lr, agent.critic_lr, agent.discount]
.iter()
.any(|n| !n.is_finite() || !(0.0..=1.0).contains(n))
|| i64::try_from(agent.steps).is_err()
{
return Err(crate::safety::arg_err(
"actor/critic rates must be finite probabilities and steps must fit Int",
));
}
Ok(())
}
fn ac_state(value: &Value, width: usize) -> Result<Vec<f64>> {
let Value::Array(values) = value else {
return Err(crate::safety::arg_err("actor/critic state must be Array"));
};
if values.len() != width {
return Err(crate::safety::arg_err(
"actor/critic state must match state_dim",
));
}
values
.iter()
.map(|v| finite_nn_number("rl_ac_update state", v))
.collect()
}
/// Create a bounded linear actor and critic with fixed rates.
fn bi_rl_ac_agent(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let name = expect_string(
"rl_ac_agent",
args.first()
.ok_or_else(|| crate::safety::arg_err("rl_ac_agent requires name"))?,
)?;
let states = rl_positive_size(
"rl_ac_agent",
args.get(1)
.ok_or_else(|| crate::safety::arg_err("rl_ac_agent requires state_dim"))?,
)?;
let actions = rl_positive_size(
"rl_ac_agent",
args.get(2)
.ok_or_else(|| crate::safety::arg_err("rl_ac_agent requires action_size"))?,
)?;
ac_dimensions(states, actions)?;
ac_agent_to_value(&ActorCriticAgent::new(
&name, states, actions, 0.01, 0.01, 0.99,
))
}
/// Update a validated copy of an actor-critic agent.
fn bi_rl_ac_update(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let piped = input.is_some();
let value = input
.or_else(|| args.first().cloned())
.ok_or_else(|| crate::safety::arg_err("rl_ac_update requires agent"))?;
let mut agent = value_to_ac_agent(&value)?;
let idx = usize::from(!piped);
let arg = |offset| {
args.get(idx + offset).ok_or_else(|| {
crate::safety::arg_err("rl_ac_update requires state, action, reward, next_state, done")
})
};
let state = ac_state(arg(0)?, agent.state_dim)?;
let action = rl_index("rl_ac_update action", arg(1)?, agent.action_size)?;
let reward = finite_nn_number("rl_ac_update reward", arg(2)?)?;
let next = ac_state(arg(3)?, agent.state_dim)?;
let Value::Bool(done) = arg(4)? else {
return Err(crate::safety::arg_err("rl_ac_update done must be Bool"));
};
if agent
.steps
.checked_add(1)
.is_none_or(|n| i64::try_from(n).is_err())
{
return Err(crate::safety::arg_err("rl_ac_update step counter overflow"));
}
agent.update(&state, action, reward, &next, *done);
validate_ac_agent(&agent).map_err(|_| {
crate::safety::bad_state(
"rl_ac_update",
"non-finite actor-critic result",
"the update exceeded the numeric range",
)
})?;
ac_agent_to_value(&agent)
}
fn validate_dqn_agent(agent: &DQNAgent) -> Result<()> {
if agent.state_dim == 0 || agent.action_size == 0 {
return Err(crate::safety::arg_err("DQN dimensions must be positive"));
}
if agent.action_size > 10000
|| agent.network.layers.len() > 127
|| agent.target_network.layers.len() > 127
{
return Err(crate::safety::budget_exceeded(
"rl_dqn",
"DQN exceeds 10000 actions or 127 dense layers".into(),
"use a smaller network",
));
}
let mut total = 0;
for net in [&agent.network, &agent.target_network] {
validate_nn(net)?;
if net
.layers
.first()
.is_none_or(|l| l.input_size != agent.state_dim)
|| net
.layers
.last()
.is_none_or(|l| l.output_size != agent.action_size)
{
return Err(crate::safety::arg_err(
"DQN network endpoints must match state/action dimensions",
));
}
for layer in &net.layers {
total = checked_nn_parameter_count(total, layer.input_size, layer.output_size)?;
}
}
if agent.network.layers.len() != agent.target_network.layers.len()
|| agent
.network
.layers
.iter()
.zip(&agent.target_network.layers)
.any(|(a, b)| (a.input_size, a.output_size) != (b.input_size, b.output_size))
{
return Err(crate::safety::arg_err(
"DQN policy and target architectures must match",
));
}
if [
agent.learning_rate,
agent.discount,
agent.epsilon,
agent.epsilon_decay,
agent.epsilon_min,
]
.iter()
.any(|n| !n.is_finite() || !(0.0..=1.0).contains(n))
|| i64::try_from(agent.steps).is_err()
|| agent.batch_size == 0
|| agent.batch_size > 10000
|| agent.target_update_freq == 0
{
return Err(crate::safety::arg_err("DQN requires finite probability rates, a representable step count, batch size in 1..=10000 and positive target frequency"));
}
let replay = &agent.replay_buffer;
if replay.capacity == 0
|| replay.buffer.len() > replay.capacity
|| replay.position >= replay.capacity
|| (replay.buffer.len() < replay.capacity && replay.position != replay.buffer.len())
{
return Err(crate::safety::arg_err(
"DQN replay capacity, length and circular position are inconsistent",
));
}
if replay.capacity > 10000
|| replay
.buffer
.len()
.checked_mul(agent.state_dim)
.and_then(|n| n.checked_mul(2))
.is_none_or(|n| n > MAX_RL_CELLS)
{
return Err(crate::safety::budget_exceeded(
"rl_dqn",
"replay exceeds 10000 capacity or one million state values".into(),
"use a smaller replay buffer",
));
}
if replay.buffer.iter().any(|exp| {
exp.state.len() != agent.state_dim
|| exp.next_state.len() != agent.state_dim
|| exp
.state
.iter()
.chain(&exp.next_state)
.any(|n| !n.is_finite())
|| exp.action >= agent.action_size
|| !exp.reward.is_finite()
}) {
return Err(crate::safety::arg_err(
"DQN experiences require exact finite state widths, valid actions and finite rewards",
));
}
Ok(())
}
/// Create bounded policy and target networks with fixed DQN defaults.
fn bi_rl_dqn_agent(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let name = expect_string(
"rl_dqn_agent",
args.first()
.ok_or_else(|| crate::safety::arg_err("rl_dqn_agent requires name"))?,
)?;
let states = rl_positive_size(
"rl_dqn_agent",
args.get(1)
.ok_or_else(|| crate::safety::arg_err("rl_dqn_agent requires state_dim"))?,
)?;
let actions = rl_positive_size(
"rl_dqn_agent",
args.get(2)
.ok_or_else(|| crate::safety::arg_err("rl_dqn_agent requires action_size"))?,
)?;
if actions > 10000 {
return Err(crate::safety::budget_exceeded(
"rl_dqn_agent",
"DQN exceeds 10000 actions".into(),
"use fewer actions",
));
}
let hidden = match args.get(3) {
None => vec![64, 64],
Some(Value::Array(values)) if values.len() <= 126 => values
.iter()
.map(|v| rl_positive_size("rl_dqn_agent hidden size", v))
.collect::<Result<Vec<_>>>()?,
Some(Value::Array(_)) => {
return Err(crate::safety::budget_exceeded(
"rl_dqn_agent",
"DQN exceeds 126 hidden layers".into(),
"use fewer layers",
))
}
Some(_) => return Err(crate::safety::arg_err("DQN hidden_sizes must be Array")),
};
let sizes: Vec<_> = std::iter::once(states)
.chain(hidden.iter().copied())
.chain(std::iter::once(actions))
.collect();
let mut total = 0;
for _ in 0..2 {
for pair in sizes.windows(2) {
total = checked_nn_parameter_count(total, pair[0], pair[1])?;
}
}
dqn_agent_to_value(&DQNAgent::new(
&name, states, actions, &hidden, 0.001, 0.99, 1.0, 10000,
))
}
/// Record experience; refuse the unimplemented training path before mutation.
fn bi_rl_dqn_step(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let piped = input.is_some();
let value = input
.or_else(|| args.first().cloned())
.ok_or_else(|| crate::safety::arg_err("rl_dqn_step requires agent"))?;
let mut agent = value_to_dqn_agent(&value)?;
let idx = usize::from(!piped);
let arg = |offset| {
args.get(idx + offset).ok_or_else(|| {
crate::safety::arg_err("rl_dqn_step requires state, action, reward, next_state, done")
})
};
let state = ac_state(arg(0)?, agent.state_dim)?;
let action = rl_index("rl_dqn_step action", arg(1)?, agent.action_size)?;
let reward = finite_nn_number("rl_dqn_step reward", arg(2)?)?;
let next = ac_state(arg(3)?, agent.state_dim)?;
let Value::Bool(done) = arg(4)? else {
return Err(crate::safety::arg_err("rl_dqn_step done must be Bool"));
};
if agent
.steps
.checked_add(1)
.is_none_or(|n| i64::try_from(n).is_err())
{
return Err(crate::safety::arg_err("rl_dqn_step counter overflow"));
}
let next_len = (agent.replay_buffer.len() + 1).min(agent.replay_buffer.capacity);
if next_len
.checked_mul(agent.state_dim)
.and_then(|n| n.checked_mul(2))
.is_none_or(|n| n > MAX_RL_CELLS)
{
return Err(crate::safety::budget_exceeded(
"rl_dqn_step",
"replay exceeds one million state values".into(),
"use a smaller buffer or state",
));
}
if next_len >= agent.batch_size
&& (agent.network.param_count() + agent.target_network.param_count())
.checked_mul(agent.batch_size)
.and_then(|work| work.checked_mul(4))
.is_none_or(|work| work > 20_000_000)
{
return Err(crate::safety::budget_exceeded(
"rl_dqn_step",
"training exceeds twenty million estimated dense operations".into(),
"use a smaller network or batch size",
));
}
agent
.step(state, action, reward, next, *done)
.map_err(|reason| {
crate::safety::bad_state(
"rl_dqn_step",
reason,
"finite training arithmetic; use smaller values or learning rate",
)
})?;
dqn_agent_to_value(&agent)
}
/// Create an empty bounded replay buffer.
fn bi_rl_replay_buffer(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let capacity = match args.first() {
None => 10000,
Some(value) => rl_positive_size("rl_replay_buffer", value)?,
};
if capacity > 10000 {
return Err(crate::safety::budget_exceeded(
"rl_replay_buffer",
"replay capacity exceeds 10000".into(),
"use a smaller capacity",
));
}
replay_buffer_to_value(&ReplayBuffer::new(capacity))
}
fn grid_dimensions(width: usize, height: usize) -> Result<usize> {
if width == 0 || height == 0 {
return Err(crate::safety::arg_err(
"gridworld dimensions must be positive",
));
}
width
.checked_mul(height)
.filter(|n| *n <= MAX_RL_CELLS)
.ok_or_else(|| {
crate::safety::budget_exceeded(
"rl_gridworld",
"gridworld exceeds one million cells".into(),
"use smaller dimensions",
)
})
}
fn validate_gridworld(env: &GridWorld) -> Result<()> {
grid_dimensions(env.width, env.height)?;
if env.agent_pos.0 >= env.width
|| env.agent_pos.1 >= env.height
|| env.goal_pos.0 >= env.width
|| env.goal_pos.1 >= env.height
|| env
.obstacles
.iter()
.any(|&(x, y)| x >= env.width || y >= env.height)
{
return Err(crate::safety::arg_err(
"gridworld positions must be within dimensions",
));
}
if env.obstacles.len() > 10000 {
return Err(crate::safety::budget_exceeded(
"rl_gridworld",
"gridworld exceeds 10000 obstacle entries".into(),
"use fewer obstacles",
));
}
Ok(())
}
/// Create a positive bounded gridworld.
fn bi_rl_gridworld(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let width = match args.first() {
None => 5,
Some(value) => rl_positive_size("rl_gridworld width", value)?,
};
let height = match args.get(1) {
None => width,
Some(value) => rl_positive_size("rl_gridworld height", value)?,
};
grid_dimensions(width, height)?;
gridworld_to_value(&GridWorld::new(width, height))
}
/// Move a validated gridworld copy by one clamped cardinal step.
fn bi_rl_env_step(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let piped = input.is_some();
let value = input
.or_else(|| args.first().cloned())
.ok_or_else(|| crate::safety::arg_err("rl_env_step requires environment"))?;
let mut env = value_to_gridworld(&value)?;
let action = rl_index(
"rl_env_step action",
args.get(usize::from(!piped))
.ok_or_else(|| crate::safety::arg_err("rl_env_step requires action"))?,
4,
)?;
let (state, reward, done) = env.step(action);
Ok(Value::Record(BTreeMap::from([
("state".into(), Value::Int(state as i64)),
("reward".into(), Value::Float(reward)),
("done".into(), Value::Bool(done)),
("env".into(), gridworld_to_value(&env)?),
])))
}
// ==================== RL Helper Functions ====================
fn q_agent_to_value(agent: &QLearningAgent) -> Result<Value> {
validate_q_agent(agent)?;
let mut rows: Vec<_> = agent
.q_table
.iter()
.map(|(&(state, action), &q)| (state, action, q))
.collect();
rows.sort_by_key(|&(state, action, _)| (state, action));
let json = serde_json::json!({
"name": agent.name, "state_size": agent.state_size, "action_size": agent.action_size,
"learning_rate": agent.learning_rate, "discount": agent.discount, "epsilon": agent.epsilon,
"epsilon_decay": agent.epsilon_decay, "epsilon_min": agent.epsilon_min, "steps": agent.steps,
"q_table": rows,
}).to_string();
let mut rec = BTreeMap::new();
rec.insert(
"type".to_string(),
Value::Str("q_learning_agent".to_string()),
);
rec.insert("name".to_string(), Value::Str(agent.name.clone()));
rec.insert(
"state_size".to_string(),
Value::Int(agent.state_size as i64),
);
rec.insert(
"action_size".to_string(),
Value::Int(agent.action_size as i64),
);
rec.insert("epsilon".to_string(), Value::Float(agent.epsilon));
rec.insert("steps".to_string(), Value::Int(agent.steps as i64));
rec.insert("_data".to_string(), Value::Str(json));
Ok(Value::Record(rec))
}
fn value_to_q_agent(value: &Value) -> Result<QLearningAgent> {
let Value::Record(record) = value else {
return Err(crate::safety::arg_err("Q-learning agent must be a Record"));
};
let Some(Value::Str(data)) = record.get("_data") else {
return Err(crate::safety::arg_err(
"Q-learning agent requires _data: String",
));
};
if data.len() > 64 * 1024 * 1024 {
return Err(crate::safety::budget_exceeded(
"rl",
"Q-learning encoding exceeds 64 MiB".into(),
"use a smaller agent",
));
}
let mut wire: serde_json::Value = serde_json::from_str(data).map_err(|e| {
crate::safety::bad_arg("rl", "valid encoded Q-learning agent", &e.to_string())
})?;
let rows = match wire.get_mut("q_table") {
Some(value @ serde_json::Value::Array(_)) => {
std::mem::replace(value, serde_json::json!({}))
}
Some(serde_json::Value::Object(table)) if table.is_empty() => serde_json::json!([]),
_ => {
return Err(crate::safety::arg_err(
"Q-learning q_table must contain row triples or a legacy empty object",
))
}
};
let mut agent: QLearningAgent = serde_json::from_value(wire).map_err(|e| {
crate::safety::bad_arg("rl", "valid encoded Q-learning agent", &e.to_string())
})?;
validate_q_agent(&agent)?;
let rows = rows.as_array().expect("row representation was checked");
if rows.len() > MAX_RL_CELLS {
return Err(crate::safety::budget_exceeded(
"rl",
"Q-learning table exceeds one million entries".into(),
"use a smaller agent",
));
}
for row in rows {
let Some(row) = row.as_array().filter(|row| row.len() == 3) else {
return Err(crate::safety::arg_err(
"Q-learning rows must be [state, action, finite value]",
));
};
let index = |value: &serde_json::Value, limit| {
value
.as_u64()
.and_then(|n| usize::try_from(n).ok())
.filter(|n| *n < limit)
.ok_or_else(|| {
crate::safety::arg_err("Q-learning row indices must be in-range integers")
})
};
let state = index(&row[0], agent.state_size)?;
let action = index(&row[1], agent.action_size)?;
let q = row[2].as_f64().filter(|n| n.is_finite()).ok_or_else(|| {
crate::safety::arg_err("Q-learning row values must be finite numbers")
})?;
if agent.q_table.insert((state, action), q).is_some() {
return Err(crate::safety::arg_err(
"Q-learning rows contain duplicate state-action pairs",
));
}
}
Ok(agent)
}
fn sarsa_agent_to_value(agent: &SarsaAgent) -> Result<Value> {
validate_sarsa_agent(agent)?;
let mut rows: Vec<_> = agent
.q_table
.iter()
.map(|(&(state, action), &q)| (state, action, q))
.collect();
rows.sort_by_key(|&(state, action, _)| (state, action));
let json = serde_json::json!({
"name": agent.name, "state_size": agent.state_size, "action_size": agent.action_size,
"learning_rate": agent.learning_rate, "discount": agent.discount, "epsilon": agent.epsilon,
"steps": agent.steps,
"q_table": rows,
})
.to_string();
let mut rec = BTreeMap::new();
rec.insert("type".to_string(), Value::Str("sarsa_agent".to_string()));
rec.insert("name".to_string(), Value::Str(agent.name.clone()));
rec.insert(
"state_size".to_string(),
Value::Int(agent.state_size as i64),
);
rec.insert(
"action_size".to_string(),
Value::Int(agent.action_size as i64),
);
rec.insert("epsilon".to_string(), Value::Float(agent.epsilon));
rec.insert("steps".to_string(), Value::Int(agent.steps as i64));
rec.insert("_data".to_string(), Value::Str(json));
Ok(Value::Record(rec))
}
fn value_to_sarsa_agent(value: &Value) -> Result<SarsaAgent> {
let Value::Record(record) = value else {
return Err(crate::safety::arg_err("SARSA agent must be a Record"));
};
let Some(Value::Str(data)) = record.get("_data") else {
return Err(crate::safety::arg_err("SARSA agent requires _data: String"));
};
if data.len() > 64 * 1024 * 1024 {
return Err(crate::safety::budget_exceeded(
"rl",
"SARSA encoding exceeds 64 MiB".into(),
"use a smaller agent",
));
}
let mut wire: serde_json::Value = serde_json::from_str(data)
.map_err(|e| crate::safety::bad_arg("rl", "valid encoded SARSA agent", &e.to_string()))?;
let rows = match wire.get_mut("q_table") {
Some(value @ serde_json::Value::Array(_)) => {
std::mem::replace(value, serde_json::json!({}))
}
Some(serde_json::Value::Object(table)) if table.is_empty() => serde_json::json!([]),
_ => {
return Err(crate::safety::arg_err(
"SARSA q_table must contain row triples or a legacy empty object",
))
}
};
let mut agent: SarsaAgent = serde_json::from_value(wire)
.map_err(|e| crate::safety::bad_arg("rl", "valid encoded SARSA agent", &e.to_string()))?;
validate_sarsa_agent(&agent)?;
let rows = rows.as_array().expect("row representation was checked");
if rows.len() > MAX_RL_CELLS {
return Err(crate::safety::budget_exceeded(
"rl",
"SARSA table exceeds one million entries".into(),
"use a smaller agent",
));
}
for row in rows {
let Some(row) = row.as_array().filter(|row| row.len() == 3) else {
return Err(crate::safety::arg_err(
"SARSA rows must be [state, action, finite value]",
));
};
let index = |value: &serde_json::Value, limit| {
value
.as_u64()
.and_then(|n| usize::try_from(n).ok())
.filter(|n| *n < limit)
.ok_or_else(|| {
crate::safety::arg_err("SARSA row indices must be in-range integers")
})
};
let state = index(&row[0], agent.state_size)?;
let action = index(&row[1], agent.action_size)?;
let q = row[2]
.as_f64()
.filter(|n| n.is_finite())
.ok_or_else(|| crate::safety::arg_err("SARSA row values must be finite numbers"))?;
if agent.q_table.insert((state, action), q).is_some() {
return Err(crate::safety::arg_err(
"SARSA rows contain duplicate state-action pairs",
));
}
}
Ok(agent)
}
fn pg_agent_to_value(agent: &PolicyGradientAgent) -> Result<Value> {
validate_pg_agent(agent)?;
let json = serde_json::to_string(agent).context("Failed to serialize PG agent")?;
if json.len() > 64 * 1024 * 1024 {
return Err(crate::safety::budget_exceeded(
"rl_pg",
"policy encoding exceeds 64 MiB".into(),
"use a smaller agent",
));
}
let mut rec = BTreeMap::new();
rec.insert(
"type".to_string(),
Value::Str("policy_gradient_agent".to_string()),
);
rec.insert("name".to_string(), Value::Str(agent.name.clone()));
rec.insert("state_dim".to_string(), Value::Int(agent.state_dim as i64));
rec.insert(
"action_size".to_string(),
Value::Int(agent.action_size as i64),
);
rec.insert("episodes".to_string(), Value::Int(agent.episodes as i64));
rec.insert("_data".to_string(), Value::Str(json));
Ok(Value::Record(rec))
}
fn value_to_pg_agent(value: &Value) -> Result<PolicyGradientAgent> {
let Value::Record(record) = value else {
return Err(crate::safety::arg_err(
"policy-gradient agent must be Record",
));
};
let Some(Value::Str(data)) = record.get("_data") else {
return Err(crate::safety::arg_err(
"policy-gradient agent requires _data: String",
));
};
if data.len() > 64 * 1024 * 1024 {
return Err(crate::safety::budget_exceeded(
"rl_pg",
"policy encoding exceeds 64 MiB".into(),
"use a smaller agent",
));
}
let agent: PolicyGradientAgent = serde_json::from_str(data).map_err(|e| {
crate::safety::bad_arg(
"rl_pg",
"valid encoded policy-gradient agent",
&e.to_string(),
)
})?;
validate_pg_agent(&agent)?;
Ok(agent)
}
fn ac_agent_to_value(agent: &ActorCriticAgent) -> Result<Value> {
validate_ac_agent(agent)?;
let json = serde_json::to_string(agent).context("Failed to serialize AC agent")?;
if json.len() > 64 * 1024 * 1024 {
return Err(crate::safety::budget_exceeded(
"rl_ac",
"actor-critic encoding exceeds 64 MiB".into(),
"use a smaller agent",
));
}
let mut rec = BTreeMap::new();
rec.insert(
"type".to_string(),
Value::Str("actor_critic_agent".to_string()),
);
rec.insert("name".to_string(), Value::Str(agent.name.clone()));
rec.insert("state_dim".to_string(), Value::Int(agent.state_dim as i64));
rec.insert(
"action_size".to_string(),
Value::Int(agent.action_size as i64),
);
rec.insert("steps".to_string(), Value::Int(agent.steps as i64));
rec.insert("_data".to_string(), Value::Str(json));
Ok(Value::Record(rec))
}
fn value_to_ac_agent(value: &Value) -> Result<ActorCriticAgent> {
let Value::Record(record) = value else {
return Err(crate::safety::arg_err("actor-critic agent must be Record"));
};
let Some(Value::Str(data)) = record.get("_data") else {
return Err(crate::safety::arg_err(
"actor-critic agent requires _data: String",
));
};
if data.len() > 64 * 1024 * 1024 {
return Err(crate::safety::budget_exceeded(
"rl_ac",
"actor-critic encoding exceeds 64 MiB".into(),
"use a smaller agent",
));
}
let agent: ActorCriticAgent = serde_json::from_str(data).map_err(|e| {
crate::safety::bad_arg("rl_ac", "valid encoded actor-critic agent", &e.to_string())
})?;
validate_ac_agent(&agent)?;
Ok(agent)
}
fn dqn_agent_to_value(agent: &DQNAgent) -> Result<Value> {
validate_dqn_agent(agent)?;
let json = serde_json::to_string(agent).context("Failed to serialize DQN agent")?;
if json.len() > 64 * 1024 * 1024 {
return Err(crate::safety::budget_exceeded(
"rl_dqn",
"DQN encoding exceeds 64 MiB".into(),
"use a smaller agent",
));
}
let mut rec = BTreeMap::new();
rec.insert("type".to_string(), Value::Str("dqn_agent".to_string()));
rec.insert("name".to_string(), Value::Str(agent.name.clone()));
rec.insert("state_dim".to_string(), Value::Int(agent.state_dim as i64));
rec.insert(
"action_size".to_string(),
Value::Int(agent.action_size as i64),
);
rec.insert("epsilon".to_string(), Value::Float(agent.epsilon));
rec.insert("steps".to_string(), Value::Int(agent.steps as i64));
rec.insert(
"buffer_size".to_string(),
Value::Int(agent.replay_buffer.len() as i64),
);
rec.insert("_data".to_string(), Value::Str(json));
Ok(Value::Record(rec))
}
fn value_to_dqn_agent(value: &Value) -> Result<DQNAgent> {
let Value::Record(record) = value else {
return Err(crate::safety::arg_err("DQN agent must be Record"));
};
let Some(Value::Str(data)) = record.get("_data") else {
return Err(crate::safety::arg_err("DQN agent requires _data: String"));
};
if data.len() > 64 * 1024 * 1024 {
return Err(crate::safety::budget_exceeded(
"rl_dqn",
"DQN encoding exceeds 64 MiB".into(),
"use a smaller agent",
));
}
let agent: DQNAgent = serde_json::from_str(data)
.map_err(|e| crate::safety::bad_arg("rl_dqn", "valid encoded DQN agent", &e.to_string()))?;
validate_dqn_agent(&agent)?;
Ok(agent)
}
fn replay_buffer_to_value(buffer: &ReplayBuffer) -> Result<Value> {
let json = serde_json::to_string(buffer).context("Failed to serialize replay buffer")?;
let mut rec = BTreeMap::new();
rec.insert("type".to_string(), Value::Str("replay_buffer".to_string()));
rec.insert("capacity".to_string(), Value::Int(buffer.capacity as i64));
rec.insert("size".to_string(), Value::Int(buffer.len() as i64));
rec.insert("_data".to_string(), Value::Str(json));
Ok(Value::Record(rec))
}
fn gridworld_to_value(env: &GridWorld) -> Result<Value> {
validate_gridworld(env)?;
let mut rec = BTreeMap::new();
rec.insert("type".to_string(), Value::Str("gridworld".to_string()));
rec.insert("width".to_string(), Value::Int(env.width as i64));
rec.insert("height".to_string(), Value::Int(env.height as i64));
rec.insert("agent_x".to_string(), Value::Int(env.agent_pos.0 as i64));
rec.insert("agent_y".to_string(), Value::Int(env.agent_pos.1 as i64));
rec.insert("goal_x".to_string(), Value::Int(env.goal_pos.0 as i64));
rec.insert("goal_y".to_string(), Value::Int(env.goal_pos.1 as i64));
rec.insert(
"obstacles".to_string(),
Value::Array(
env.obstacles
.iter()
.map(|&(x, y)| Value::Array(vec![Value::Int(x as i64), Value::Int(y as i64)]))
.collect(),
),
);
rec.insert(
"state_size".to_string(),
Value::Int(env.state_size() as i64),
);
rec.insert(
"action_size".to_string(),
Value::Int(env.action_size() as i64),
);
Ok(Value::Record(rec))
}
fn value_to_gridworld(value: &Value) -> Result<GridWorld> {
let Value::Record(record) = value else {
return Err(crate::safety::arg_err(
"gridworld environment must be Record",
));
};
if let Some(value) = record.get("type") {
if !matches!(value, Value::Str(kind) if kind == "gridworld") {
return Err(crate::safety::arg_err("gridworld type must be gridworld"));
}
}
let width = rl_positive_size(
"gridworld width",
record
.get("width")
.ok_or_else(|| crate::safety::arg_err("gridworld requires width"))?,
)?;
let height = rl_positive_size(
"gridworld height",
record
.get("height")
.ok_or_else(|| crate::safety::arg_err("gridworld requires height"))?,
)?;
let cells = grid_dimensions(width, height)?;
let position = |key: &str, default, limit| -> Result<usize> {
match record.get(key) {
None => Ok(default),
Some(value) => rl_index(key, value, limit),
}
};
let mut env = GridWorld::new(width, height);
env.agent_pos = (
position("agent_x", 0, width)?,
position("agent_y", 0, height)?,
);
env.goal_pos = (
position("goal_x", width - 1, width)?,
position("goal_y", height - 1, height)?,
);
for (key, expected) in [("state_size", cells as i64), ("action_size", 4)] {
if let Some(value) = record.get(key) {
if !matches!(value, Value::Int(n) if *n == expected) {
return Err(crate::safety::arg_err(format!(
"gridworld {key} must match dimensions"
)));
}
}
}
if let Some(value) = record.get("obstacles") {
let Value::Array(rows) = value else {
return Err(crate::safety::arg_err(
"gridworld obstacles must be Array of [x,y] pairs",
));
};
if rows.len() > 10000 {
return Err(crate::safety::budget_exceeded(
"rl_gridworld",
"gridworld exceeds 10000 obstacle entries".into(),
"use fewer obstacles",
));
}
for row in rows {
let Value::Array(pair) = row else {
return Err(crate::safety::arg_err(
"gridworld obstacles must be [x,y] pairs",
));
};
if pair.len() != 2 {
return Err(crate::safety::arg_err(
"gridworld obstacles must be [x,y] pairs",
));
}
env.obstacles.push((
rl_index("obstacle x", &pair[0], width)?,
rl_index("obstacle y", &pair[1], height)?,
));
}
}
validate_gridworld(&env)?;
Ok(env)
}
// ===========================================================================
// OS Tools Builtins
// ===========================================================================
/// List all available tools or filter by category/OS
/// Usage: tools() | tools("network") | tools("linux")
fn bi_tools(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let db = OSToolsDatabase::shared();
// A filter that is not a string used to be ignored, so `tools(5)` listed
// the whole catalogue as if no filter had been given.
if args.len() > 1 {
return Err(crate::safety::bad_arg(
"tools",
"at most one filter argument",
&format!("{} arguments", args.len()),
));
}
let filter = match args.first() {
None | Some(Value::Null) => None,
Some(Value::Str(s)) => Some(s.to_lowercase()),
Some(other) => {
return Err(crate::safety::bad_arg(
"tools",
"a filter: an OS, a category, or a search string",
other.type_name(),
))
}
};
let tools: Vec<&crate::os_tools::OSTool> = if let Some(ref f) = filter {
// Check if it's an OS filter
let os_filter = match f.as_str() {
"linux" => Some(OperatingSystem::Linux),
"bsd" | "freebsd" | "openbsd" | "netbsd" => Some(OperatingSystem::BSD),
"macos" | "mac" | "darwin" => Some(OperatingSystem::MacOS),
"windows" | "win" => Some(OperatingSystem::Windows),
"ios" | "iphone" => Some(OperatingSystem::iOS),
"android" => Some(OperatingSystem::Android),
_ => None,
};
if let Some(os) = os_filter {
db.get_tools_by_os(&os)
} else {
// Check if it's a category filter
let cat_filter = match f.as_str() {
"filesystem" | "file" | "files" => Some(ToolCategory::FileSystem),
"text" | "textprocessing" => Some(ToolCategory::TextProcessing),
"network" | "net" => Some(ToolCategory::NetworkTools),
"system" | "systeminfo" | "sysinfo" => Some(ToolCategory::SystemInfo),
"process" | "processes" => Some(ToolCategory::ProcessManagement),
"archive" | "archives" | "compression" => Some(ToolCategory::Archives),
"search" => Some(ToolCategory::SearchTools),
"monitor" | "monitoring" => Some(ToolCategory::Monitoring),
"dev" | "development" => Some(ToolCategory::Development),
"media" => Some(ToolCategory::Media),
"security" | "sec" => Some(ToolCategory::Security),
"util" | "utilities" => Some(ToolCategory::Utilities),
"web" | "webtools" => Some(ToolCategory::WebTools),
"cyber" | "cybersecurity" | "pentest" => Some(ToolCategory::CyberSecurity),
"recon" | "reconnaissance" | "osint" => Some(ToolCategory::Reconnaissance),
"forensics" | "forensic" => Some(ToolCategory::Forensics),
"crypto" | "cryptography" => Some(ToolCategory::Cryptography),
_ => None,
};
if let Some(cat) = cat_filter {
db.get_tools_by_category(&cat)
} else {
// Fallback to search
db.search_tools(f)
}
}
} else {
// Return all tools
db.tools.values().collect()
};
// Sorted, because `db.tools` is a `HashMap` and Rust randomises its
// iteration order per process: `tools()` returned the same set in a
// different order on every call. This is the agent-facing tool catalogue,
// the thing an agent is supposed to fetch once and cache, and it could not
// be diffed, cached or hashed. Determinism is one of the four axes
// `docs/TYPED_SHELL_RESPONSE.md` argues on, and E1 measured it only over
// data queries -- never over the discovery surface itself.
let mut tools = tools;
tools.sort_by(|a, b| a.name.cmp(&b.name));
// Convert to Value::Array of Records
let tool_values: Vec<Value> = tools
.iter()
.map(|t| {
let mut rec = BTreeMap::new();
rec.insert("name".to_string(), Value::Str(t.name.clone()));
rec.insert("description".to_string(), Value::Str(t.description.clone()));
rec.insert("command".to_string(), Value::Str(t.command.clone()));
rec.insert(
"category".to_string(),
Value::Str(format!("{:?}", t.category)),
);
rec.insert(
"safety".to_string(),
Value::Str(format!("{:?}", t.safety_level)),
);
rec.insert("requires_admin".to_string(), Value::Bool(t.requires_admin));
rec.insert(
"supported_os".to_string(),
Value::Array(
t.supported_os
.iter()
.map(|os| Value::Str(format!("{:?}", os)))
.collect(),
),
);
Value::Record(rec)
})
.collect();
Ok(Value::Array(tool_values))
}
/// Get detailed information about a specific tool
/// Usage: tool_info("curl") | tool_info("nmap")
fn bi_tool_info(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let tool_name = args
.first()
.and_then(|v| {
if let Value::Str(s) = v {
Some(s.clone())
} else {
None
}
})
.ok_or_else(|| crate::safety::arg_err("tool_info requires tool name as argument"))?;
let db = OSToolsDatabase::shared();
let tool = db
.get_tool(&tool_name)
.ok_or_else(|| crate::safety::not_found("tool_info", "tool", &tool_name.to_string()))?;
let mut rec = BTreeMap::new();
rec.insert("name".to_string(), Value::Str(tool.name.clone()));
rec.insert(
"description".to_string(),
Value::Str(tool.description.clone()),
);
rec.insert("command".to_string(), Value::Str(tool.command.clone()));
rec.insert(
"category".to_string(),
Value::Str(format!("{:?}", tool.category)),
);
rec.insert(
"safety_level".to_string(),
Value::Str(format!("{:?}", tool.safety_level)),
);
rec.insert(
"requires_admin".to_string(),
Value::Bool(tool.requires_admin),
);
rec.insert(
"supported_os".to_string(),
Value::Array(
tool.supported_os
.iter()
.map(|os| Value::Str(format!("{:?}", os)))
.collect(),
),
);
rec.insert(
"common_args".to_string(),
Value::Array(
tool.common_args
.iter()
.map(|a| Value::Str(a.clone()))
.collect(),
),
);
// Add examples
let examples: Vec<Value> = tool
.examples
.iter()
.map(|ex| {
let mut ex_rec = BTreeMap::new();
ex_rec.insert(
"description".to_string(),
Value::Str(ex.description.clone()),
);
ex_rec.insert("command".to_string(), Value::Str(ex.command.clone()));
if let Some(ref output) = ex.expected_output {
ex_rec.insert("expected_output".to_string(), Value::Str(output.clone()));
}
Value::Record(ex_rec)
})
.collect();
rec.insert("examples".to_string(), Value::Array(examples));
// Add parameters if available
let params: Vec<Value> = tool
.parameters
.iter()
.map(|p| {
let mut p_rec = BTreeMap::new();
p_rec.insert("name".to_string(), Value::Str(p.name.clone()));
p_rec.insert("description".to_string(), Value::Str(p.description.clone()));
p_rec.insert(
"type".to_string(),
Value::Str(format!("{:?}", p.param_type)),
);
p_rec.insert("required".to_string(), Value::Bool(p.required));
if let Some(ref default) = p.default_value {
p_rec.insert("default".to_string(), Value::Str(default.clone()));
}
if !p.enum_values.is_empty() {
p_rec.insert(
"enum_values".to_string(),
Value::Array(
p.enum_values
.iter()
.map(|v| Value::Str(v.clone()))
.collect(),
),
);
}
Value::Record(p_rec)
})
.collect();
rec.insert("parameters".to_string(), Value::Array(params));
Ok(Value::Record(rec))
}
/// Get OpenAI-compatible function calling schemas for tools
/// Usage: tool_schema("curl") | tool_schema() for all | tool_schema("network") for category
fn bi_tool_schema(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let db = OSToolsDatabase::shared();
let filter = match args.first() {
None => None,
Some(Value::Str(s)) => Some(s.clone()),
Some(other) => {
return Err(crate::safety::bad_arg(
"tool_schema",
"filter: String",
other.type_name(),
));
}
};
let schemas = if let Some(ref name) = filter {
// Check if it's a specific tool
if let Some(tool) = db.get_tool(name) {
vec![tool.to_openai_function_schema()]
} else {
// Check if it's a category
let cat = match name.to_lowercase().as_str() {
"network" | "net" => Some(ToolCategory::NetworkTools),
"web" | "webtools" => Some(ToolCategory::WebTools),
"cyber" | "cybersecurity" => Some(ToolCategory::CyberSecurity),
"filesystem" | "file" => Some(ToolCategory::FileSystem),
"security" | "sec" => Some(ToolCategory::Security),
"forensics" => Some(ToolCategory::Forensics),
"crypto" | "cryptography" => Some(ToolCategory::Cryptography),
_ => None,
};
if let Some(c) = cat {
db.get_category_schemas(&c)
} else {
// Search and return schemas for matching tools
db.search_tools(name)
.iter()
.map(|t| t.to_openai_function_schema())
.collect()
}
}
} else {
// Return all schemas
db.to_openai_function_schemas()
};
// Convert serde_json::Value to our Value
let schema_values: Vec<Value> = schemas.into_iter().map(json_to_value).collect();
Ok(Value::Array(schema_values))
}
/// Execute a tool with given arguments
/// Usage: tool_exec("ls", ["-la"]) | tool_exec("curl", ["-s", "https://example.com"])
fn bi_tool_exec(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
if args.is_empty() {
return Err(crate::safety::arg_err(
"tool_exec requires: tool_name, [args], [allow_dangerous]",
));
}
let tool_name = if let Value::Str(s) = &args[0] {
s.clone()
} else {
return Err(crate::safety::arg_err(
"tool_exec: first argument must be tool name string",
));
};
if args.len() > 3 {
return Err(crate::safety::bad_arg(
"tool_exec",
"at most 3 arguments: tool_name, [args], [allow_dangerous]",
&format!("{} arguments", args.len()),
));
}
// Every argument is passed to the program as given, so a value that is not
// a string used to be dropped from the argv (changing what ran) or the
// whole list was discarded. Refuse those instead.
let tool_args: Vec<String> = match args.get(1) {
None | Some(Value::Null) => vec![],
Some(Value::Str(s)) => vec![s.clone()],
Some(Value::Array(arr)) => {
let mut out = Vec::with_capacity(arr.len());
for (i, v) in arr.iter().enumerate() {
match v {
Value::Str(s) if !s.contains('\0') => out.push(s.clone()),
other => {
return Err(crate::safety::bad_arg(
"tool_exec",
&format!("args[{i}]: a string without NUL"),
other.type_name(),
))
}
}
}
out
}
Some(other) => {
return Err(crate::safety::bad_arg(
"tool_exec",
"args: an Array<String> or a single String",
other.type_name(),
))
}
};
let allow_dangerous = match args.get(2) {
None | Some(Value::Null) => false,
Some(Value::Bool(b)) => *b,
Some(other) => {
return Err(crate::safety::bad_arg(
"tool_exec",
"allow_dangerous: a boolean",
other.type_name(),
))
}
};
let db = OSToolsDatabase::shared();
let tool = db
.get_tool(&tool_name)
.ok_or_else(|| crate::safety::not_found("tool_exec", "tool", &tool_name))?;
if !allow_dangerous
&& matches!(
tool.safety_level,
crate::os_tools::SafetyLevel::Dangerous | crate::os_tools::SafetyLevel::Critical
)
{
return Err(crate::safety::policy_deny(
"tool_exec",
format!("tool '{tool_name}' is {:?}", tool.safety_level),
"pass allow_dangerous=true, which needs the same approval as any gated exec",
));
}
crate::safety::guard_exec(
"tool_exec",
format!("{} {}", tool_name, tool_args.join(" ")),
)?;
// A refusal before the run is a policy denial. Everything else is already
// coded: a missing program (E_TOOL_MISSING), a run past its deadline
// (E_BUDGET_EXCEEDED, the child killed) or a failure to start.
let result = execute_tool_safe(&db, &tool_name, &tool_args, allow_dangerous).map_err(|e| {
if e.is::<crate::os_tools::ToolRefusal>() {
let message = e.to_string();
crate::safety::policy_deny(
"tool_exec",
message,
"see the tool's safety and OS requirements",
)
} else {
e
}
})?;
// Convert result to Value::Record
let mut rec = BTreeMap::new();
rec.insert("success".to_string(), Value::Bool(result.success));
rec.insert("stdout".to_string(), Value::Str(result.stdout));
rec.insert(
"stdout_truncated".to_string(),
Value::Bool(result.stdout_truncated),
);
rec.insert("stderr".to_string(), Value::Str(result.stderr));
rec.insert(
"stderr_truncated".to_string(),
Value::Bool(result.stderr_truncated),
);
rec.insert(
"exit_code".to_string(),
result
.exit_code
.map(|c| Value::Int(c as i64))
.unwrap_or(Value::Null),
);
rec.insert("tool_name".to_string(), Value::Str(result.tool_name));
rec.insert(
"command_executed".to_string(),
Value::Str(result.command_executed),
);
rec.insert(
"execution_time_ms".to_string(),
Value::Int(result.execution_time_ms as i64),
);
Ok(Value::Record(rec))
}
/// Search for tools by keyword or description
/// Usage: tool_search("network") | tool_search("file copy")
fn bi_tool_search(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let query = args
.first()
.and_then(|v| {
if let Value::Str(s) = v {
Some(s.clone())
} else {
None
}
})
.ok_or_else(|| crate::safety::arg_err("tool_search requires a search query"))?;
let db = OSToolsDatabase::shared();
// Use get_recommended_tools for smart search
let tools = db.get_recommended_tools(&query);
// If no recommendations, fall back to regular search
let tools = if tools.is_empty() {
db.search_tools(&query)
} else {
tools
};
let tool_values: Vec<Value> = tools
.iter()
.map(|t| {
let mut rec = BTreeMap::new();
rec.insert("name".to_string(), Value::Str(t.name.clone()));
rec.insert("description".to_string(), Value::Str(t.description.clone()));
rec.insert("command".to_string(), Value::Str(t.command.clone()));
rec.insert(
"category".to_string(),
Value::Str(format!("{:?}", t.category)),
);
rec.insert(
"safety".to_string(),
Value::Str(format!("{:?}", t.safety_level)),
);
Value::Record(rec)
})
.collect();
Ok(Value::Array(tool_values))
}
// ===========================================================================
// Recursive Language Model (RLM) Builtins
// ===========================================================================
/// Run a recursive agent that can spawn subagents
/// Usage: rlm_agent("goal", ["tool1", "tool2"])
/// rlm_agent("goal", ["tools"], {max_depth: 3, max_agents: 20})
fn bi_rlm_agent(args: Vec<Value>, _input: Option<Value>, env: &mut Env) -> Result<Value> {
if args.is_empty() {
return Err(crate::safety::arg_err(
"rlm_agent requires: goal, [tools], [config]",
));
}
let goal = if let Value::Str(s) = &args[0] {
s.clone()
} else {
return Err(crate::safety::arg_err(
"rlm_agent: first argument must be goal string",
));
};
if args.len() > 4 {
return Err(crate::safety::bad_arg(
"rlm_agent",
"at most 4 arguments: goal, [tools], [config], [model_uri]",
&format!("{} arguments", args.len()),
));
}
let tool_names = match args.get(1) {
None => rlm_default_tools(),
Some(v) => rlm_tool_list("rlm_agent", v)?,
};
let config = match args.get(2) {
None => RlmConfig::default(),
Some(v) => parse_rlm_config(v)?,
};
let model_uri = match args.get(3) {
None | Some(Value::Null) => None,
Some(Value::Str(s)) if !s.is_empty() => Some(s.clone()),
Some(other) => {
return Err(crate::safety::bad_arg(
"rlm_agent",
"model_uri: a non-empty string",
other.type_name(),
))
}
};
let tool_refs: Vec<&str> = tool_names.iter().map(|s| s.as_str()).collect();
let (result, stats) = if let Some(uri) = model_uri {
run_recursive_with_model(&goal, &tool_refs, &uri, config, false, env)?
} else {
run_recursive(&goal, &tool_refs, config, false, env)?
};
record_rlm_run(&stats);
// Return result with stats
let mut rec = BTreeMap::new();
rec.insert("output".to_string(), Value::Str(result));
rec.insert(
"total_agents".to_string(),
Value::Int(stats.total_spawned as i64),
);
rec.insert(
"active_agents".to_string(),
Value::Int(stats.currently_active as i64),
);
rec.insert(
"messages_sent".to_string(),
Value::Int(stats.messages_sent as i64),
);
rec.insert(
"elapsed_ms".to_string(),
Value::Int(stats.elapsed_ms as i64),
);
Ok(Value::Record(rec))
}
/// Create an RLM configuration
/// Usage: rlm_config({max_depth: 5, max_agents: 50, timeout: 60})
fn bi_rlm_config(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let config = if args.is_empty() {
RlmConfig::default()
} else {
parse_rlm_config(&args[0])?
};
let mut rec = BTreeMap::new();
rec.insert("max_depth".to_string(), Value::Int(config.max_depth as i64));
rec.insert(
"max_agents".to_string(),
Value::Int(config.max_agents as i64),
);
rec.insert(
"agent_timeout_secs".to_string(),
Value::Int(config.agent_timeout_secs as i64),
);
rec.insert(
"max_concurrent_children".to_string(),
Value::Int(config.max_concurrent_children as i64),
);
rec.insert(
"trace_enabled".to_string(),
Value::Bool(config.trace_enabled),
);
if let Some(uri) = config.subagent_model_uri {
rec.insert("subagent_model_uri".to_string(), Value::Str(uri));
}
Ok(Value::Record(rec))
}
/// Get RLM statistics from the last run
/// Usage: rlm_stats()
/// The last recursive-agent run's statistics, and how many runs there were.
static LAST_RLM_RUN: std::sync::Mutex<Option<(u64, crate::rlm::HierarchyStats)>> =
std::sync::Mutex::new(None);
fn record_rlm_run(stats: &crate::rlm::HierarchyStats) {
let mut last = LAST_RLM_RUN.lock().unwrap_or_else(|e| e.into_inner());
let runs = last.as_ref().map_or(0, |(n, _)| *n) + 1;
*last = Some((runs, stats.clone()));
}
fn bi_rlm_stats(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
// Given the record an rlm run returned, report it.
match input.or_else(|| args.first().cloned()) {
Some(Value::Record(rec)) => return Ok(Value::Record(rec)),
Some(other) => {
return Err(crate::safety::bad_arg(
"rlm_stats",
"nothing, or the record an rlm run returned",
other.type_name(),
))
}
None => {}
}
// With no argument this returned constants -- max_depth 5, max_agents 50,
// zero spawned -- presented as "statistics from the last run", whether or
// not anything had run. Nothing recorded runs. rlm_agent and rlm_spawn
// now do, and this reports the last one; before any run, `runs` is 0 and
// there is no last run to describe.
let last = LAST_RLM_RUN
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
let mut rec = BTreeMap::new();
rec.insert(
"runs".to_string(),
Value::Int(last.as_ref().map_or(0, |(n, _)| *n as i64)),
);
let last = last.map(|(_, st)| {
let mut r = BTreeMap::new();
r.insert(
"total_spawned".to_string(),
Value::Int(st.total_spawned as i64),
);
r.insert(
"currently_active".to_string(),
Value::Int(st.currently_active as i64),
);
r.insert(
"messages_sent".to_string(),
Value::Int(st.messages_sent as i64),
);
r.insert("elapsed_ms".to_string(), Value::Int(st.elapsed_ms as i64));
r.insert("max_depth".to_string(), Value::Int(st.max_depth as i64));
r.insert("max_agents".to_string(), Value::Int(st.max_agents as i64));
Value::Record(r)
});
rec.insert("last_run".to_string(), last.unwrap_or(Value::Null));
Ok(Value::Record(rec))
}
/// Spawn a subagent (used in agent context)
/// Usage: rlm_spawn("name", "goal", ["tools"])
fn bi_rlm_spawn(args: Vec<Value>, _input: Option<Value>, env: &mut Env) -> Result<Value> {
if args.len() < 2 {
return Err(crate::safety::arg_err(
"rlm_spawn requires: name, goal, [tools]",
));
}
let name = if let Value::Str(s) = &args[0] {
s.clone()
} else {
return Err(crate::safety::arg_err("rlm_spawn: name must be a string"));
};
let goal = if let Value::Str(s) = &args[1] {
s.clone()
} else {
return Err(crate::safety::arg_err("rlm_spawn: goal must be a string"));
};
if args.len() > 3 {
return Err(crate::safety::bad_arg(
"rlm_spawn",
"at most 3 arguments: name, goal, [tools]",
&format!("{} arguments", args.len()),
));
}
let tool_names = match args.get(2) {
None => vec!["ls".to_string(), "cat".to_string()],
Some(v) => rlm_tool_list("rlm_spawn", v)?,
};
// Run a single-depth recursive agent for the spawn. Every bound here is a
// literal, not a caller choice: one level, five agents, thirty seconds.
let config = RlmConfig {
max_depth: 1, // Single level only
max_agents: 5,
agent_timeout_secs: 30,
..Default::default()
};
// Spawning an autonomous sub-agent is an execution decision in its own right:
// the individual tool calls it makes are guarded at their own chokepoints,
// but *whether to start it at all* is the one point a policy can weigh.
crate::safety::guard_exec("rlm_spawn", format!("spawn agent '{name}': {goal}"))?;
let tool_refs: Vec<&str> = tool_names.iter().map(|s| s.as_str()).collect();
let (result, stats) = run_recursive(&goal, &tool_refs, config, false, env)?;
record_rlm_run(&stats);
let mut rec = BTreeMap::new();
rec.insert("name".to_string(), Value::Str(name));
rec.insert("goal".to_string(), Value::Str(goal));
// `run_sync` answers a run that hit its step cap with an Ok string that
// begins "(incomplete)", not with an error. That is not a success, however
// many agents it spawned.
let complete = !result.starts_with("(incomplete)");
rec.insert("output".to_string(), Value::Str(result));
rec.insert("complete".to_string(), Value::Bool(complete));
rec.insert(
"success".to_string(),
Value::Bool(stats.total_spawned > 0 && complete),
);
rec.insert(
"agents_used".to_string(),
Value::Int(stats.total_spawned as i64),
);
rec.insert(
"elapsed_ms".to_string(),
Value::Int(stats.elapsed_ms as i64),
);
Ok(Value::Record(rec))
}
/// Hard ceilings on the recursion budgets a caller may choose. A request above
/// one is refused with `E_BUDGET_EXCEEDED`, not clamped: a silently smaller
/// run than the one asked for is a result nobody can reason about.
const RLM_MAX_DEPTH_CEILING: i64 = 8;
const RLM_MAX_AGENTS_CEILING: i64 = 200;
const RLM_MAX_CHILDREN_CEILING: i64 = 32;
const RLM_TIMEOUT_CEILING_SECS: i64 = 3600;
/// The tool names a recursive agent gets when the caller names none.
fn rlm_default_tools() -> Vec<String> {
["ls", "cat", "grep", "find"]
.iter()
.map(|s| s.to_string())
.collect()
}
/// A caller's tool list: an array of names of builtins that exist. A name that
/// does not is refused rather than dropped, so the agent never runs with fewer
/// tools than it was given.
fn rlm_tool_list(builtin: &str, value: &Value) -> Result<Vec<String>> {
let arr = match value {
Value::Null => return Ok(rlm_default_tools()),
Value::Array(arr) => arr,
other => {
return Err(crate::safety::bad_arg(
builtin,
"tools: Array<String> of builtin names",
other.type_name(),
))
}
};
let mut out = Vec::with_capacity(arr.len());
for (i, v) in arr.iter().enumerate() {
match v {
Value::Str(s) if crate::builtins::is_dispatched(s) => out.push(s.clone()),
Value::Str(s) => {
return Err(crate::safety::not_found(builtin, "builtin", s));
}
other => {
return Err(crate::safety::bad_arg(
builtin,
&format!("tools[{i}]: a builtin name string"),
other.type_name(),
))
}
}
}
Ok(out)
}
/// One integer budget from a config record, between `min` and `ceiling`.
fn rlm_budget(key: &str, v: &Value, min: i64, ceiling: i64) -> Result<i64> {
match v {
Value::Int(n) if *n > ceiling => Err(crate::safety::budget_exceeded(
"rlm_agent",
format!("{key} = {n} is above the ceiling of {ceiling}"),
"lower it; the ceiling is fixed so one call cannot fan out without bound",
)),
Value::Int(n) if *n >= min => Ok(*n),
Value::Int(n) => Err(crate::safety::bad_arg(
"rlm_agent",
&format!("{key}: an integer from {min} to {ceiling}"),
&n.to_string(),
)),
other => Err(crate::safety::bad_arg(
"rlm_agent",
&format!("{key}: an integer from {min} to {ceiling}"),
other.type_name(),
)),
}
}
/// Parse RLM configuration from a Value.
///
/// Every key must be one this reads, and every value must have the right type
/// and lie within its ceiling. A negative count used to wrap to a huge `usize`
/// (an effectively unbounded depth or agent count), and an unknown key or a
/// wrong type used to fall back to the default with no word.
fn parse_rlm_config(value: &Value) -> Result<RlmConfig> {
const KEYS: &[&str] = &[
"max_depth",
"max_agents",
"timeout",
"agent_timeout_secs",
"max_concurrent_children",
"trace_enabled",
"trace",
"model_uri",
"subagent_model_uri",
];
let rec = match value {
Value::Record(rec) => rec,
other => {
return Err(crate::safety::bad_arg(
"rlm_agent",
"config: a Record of recursion budgets",
other.type_name(),
))
}
};
if let Some(k) = rec.keys().find(|k| !KEYS.contains(&k.as_str())) {
return Err(crate::safety::bad_arg(
"rlm_agent",
&format!("config: a known key (one of {})", KEYS.join(", ")),
&format!("key '{k}'"),
));
}
let mut config = RlmConfig::default();
if let Some(v) = rec.get("max_depth") {
config.max_depth = rlm_budget("max_depth", v, 0, RLM_MAX_DEPTH_CEILING)? as usize;
}
if let Some(v) = rec.get("max_agents") {
config.max_agents = rlm_budget("max_agents", v, 1, RLM_MAX_AGENTS_CEILING)? as usize;
}
for key in ["timeout", "agent_timeout_secs"] {
if let Some(v) = rec.get(key) {
config.agent_timeout_secs = rlm_budget(key, v, 1, RLM_TIMEOUT_CEILING_SECS)? as u64;
}
}
if let Some(v) = rec.get("max_concurrent_children") {
config.max_concurrent_children =
rlm_budget("max_concurrent_children", v, 1, RLM_MAX_CHILDREN_CEILING)? as usize;
}
for key in ["trace_enabled", "trace"] {
match rec.get(key) {
None => {}
Some(Value::Bool(b)) => config.trace_enabled = *b,
Some(other) => {
return Err(crate::safety::bad_arg(
"rlm_agent",
&format!("{key}: a boolean"),
other.type_name(),
))
}
}
}
for key in ["model_uri", "subagent_model_uri"] {
match rec.get(key) {
None => {}
Some(Value::Str(s)) if !s.is_empty() => config.subagent_model_uri = Some(s.clone()),
Some(other) => {
return Err(crate::safety::bad_arg(
"rlm_agent",
&format!("{key}: a non-empty string"),
other.type_name(),
))
}
}
}
Ok(config)
}
// =============== New Aspirational Feature Builtins ===============
/// sh(args) - Execute a shell command directly
/// Usage: sh(["echo", "hello"]) or sh("echo hello")
///
/// SECURITY: Gated behind IRONSHELL_ALLOW_SH=true environment variable.
/// Split a command line into words as a POSIX shell does, without any of the
/// expansions a shell would then perform.
///
/// Whitespace separates words; `'...'` is literal; `"..."` is literal except
/// that `\` escapes `"`, `\`, `$` and a backquote; outside quotes `\` escapes
/// the next character. Adjacent pieces join into one word (`a"b c"d` is
/// `ab cd`), and `''` is an empty argument rather than nothing.
fn split_command_words(s: &str) -> std::result::Result<Vec<String>, String> {
let mut words = Vec::new();
let mut cur = String::new();
let mut in_word = false;
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
match c {
'\'' => {
in_word = true;
loop {
match chars.next() {
Some('\'') => break,
Some(ch) => cur.push(ch),
None => return Err("an unterminated single quote".into()),
}
}
}
'"' => {
in_word = true;
loop {
match chars.next() {
Some('"') => break,
Some('\\') => match chars.peek() {
Some(&n) if matches!(n, '"' | '\\' | '$' | '`') => {
cur.push(n);
chars.next();
}
_ => cur.push('\\'),
},
Some(ch) => cur.push(ch),
None => return Err("an unterminated double quote".into()),
}
}
}
'\\' => {
in_word = true;
match chars.next() {
Some(n) => cur.push(n),
None => return Err("a trailing backslash".into()),
}
}
c if c.is_whitespace() => {
if in_word {
words.push(std::mem::take(&mut cur));
in_word = false;
}
}
c => {
in_word = true;
cur.push(c);
}
}
}
if in_word {
words.push(cur);
}
Ok(words)
}
#[cfg(test)]
mod split_command_words_tests {
use super::split_command_words;
fn split(s: &str) -> Vec<String> {
split_command_words(s).unwrap()
}
#[test]
fn quoted_arguments_stay_whole() {
assert_eq!(
split("git commit -m 'fix bug'"),
["git", "commit", "-m", "fix bug"]
);
assert_eq!(split(r#"echo "a \"b\" c""#), ["echo", r#"a "b" c"#]);
assert_eq!(split(r"touch my\ file"), ["touch", "my file"]);
}
#[test]
fn adjacent_quotes_join_and_empty_quotes_are_words() {
assert_eq!(split(r#"a'b'"c" ''"#), ["abc", ""]);
}
#[test]
fn nothing_is_expanded() {
assert_eq!(split("echo $HOME '*' *"), ["echo", "$HOME", "*", "*"]);
assert_eq!(split(r"echo 'a\nb'"), ["echo", r"a\nb"]);
}
#[test]
fn whitespace_only_is_no_words() {
assert!(split(" \t ").is_empty());
}
#[test]
fn malformed_input_is_refused() {
assert!(split_command_words("echo 'open").is_err());
assert!(split_command_words("echo \"open").is_err());
assert!(split_command_words("echo \\").is_err());
}
}
fn bi_sh(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("sh", &input)?;
k8s_max_args("sh", &args, 1)?;
if args.is_empty() {
return Err(crate::safety::bad_arg(
"sh",
"a command String or an argv Array",
"nothing",
));
}
let (program, cmd_args): (String, Vec<String>) = match &args[0] {
Value::Str(s) => {
// Split the way a POSIX shell would split WORDS -- quotes and
// backslashes honoured -- but without running a shell: no
// globbing, no variables, no pipes. This was `split_whitespace`,
// which ignored quotes entirely, so
// `sh("git commit -m 'fix bug'")` passed `'fix` and `bug'` as two
// arguments: the commit message became `'fix`, and `bug'` was
// taken as a path. Found while writing a probe whose control call
// was `bash -c '...'` and never ran.
let parts = split_command_words(s).map_err(|why| {
crate::safety::bad_arg(
"sh",
"a command with balanced quotes, or an array of arguments",
&why,
)
})?;
if parts.is_empty() {
return Err(crate::safety::bad_arg(
"sh",
"a non-empty command",
"an empty string",
));
}
(parts[0].clone(), parts[1..].to_vec())
}
Value::Array(arr) => {
if arr.is_empty() {
return Err(crate::safety::bad_arg(
"sh",
"a non-empty command",
"an empty array",
));
}
let program = match &arr[0] {
Value::Str(s) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"sh",
"a program name String as the first element",
other.type_name(),
))
}
};
let args: Result<Vec<String>> = arr[1..]
.iter()
.map(|v| match v {
Value::Str(s) => Ok(s.clone()),
Value::Int(n) => Ok(n.to_string()),
Value::Float(f) => Ok(f.to_string()),
other => Err(crate::safety::bad_arg(
"sh",
"arguments that are strings or numbers",
other.type_name(),
)),
})
.collect();
(program, args?)
}
other => {
return Err(crate::safety::bad_arg(
"sh",
"a command String or an argv Array",
other.type_name(),
))
}
};
// Security gate: require explicit opt-in. Checked after the shape, so a
// malformed call says what is wrong with it whether or not sh is enabled.
validate_sh_allowed()?;
crate::safety::guard(crate::safety::GuardCtx {
builtin: "sh",
effect: crate::safety::Effect::Exec,
what: "exec",
targets: vec![format!("{} {}", program, cmd_args.join(" "))
.trim()
.to_string()],
blast_radius: serde_json::json!({ "program": program, "args": cmd_args }),
reversible: false,
fs_paths: false,
})?;
// Directly, never through a shell, with stdin closed. This was
// `Command::output()`: no deadline, so a program that never exits held the
// session forever, and both streams were kept whole, so one that printed
// without end grew without bound. A missing program was an uncoded error.
let argv: Vec<&str> = cmd_args.iter().map(String::as_str).collect();
let run = tool_run_stderr(
"sh",
&program,
None,
&argv,
Keep::Tail(TOOL_KEEP_TAIL),
TOOL_KEEP_TAIL,
TOOL_LONG_DEADLINE_SECS,
)?;
// A nonzero exit is the program's answer, not a failure of `sh`: the record
// carries it, as a shell's `$?` does.
let mut record = BTreeMap::new();
record.insert("stdout".to_string(), Value::Str(run.stdout));
record.insert("stderr".to_string(), Value::Str(run.stderr));
record.insert("success".to_string(), Value::Bool(run.success));
record.insert(
"exit_code".to_string(),
Value::Int(run.code.unwrap_or(-1) as i64),
);
record.insert(
"stdout_truncated".to_string(),
Value::Bool(run.stdout_truncated),
);
Ok(Value::Record(record))
}
// ─────────────────────────────────────────────────────────────────────────
// Output economy (token efficiency): AECON compact rendering + token estimate.
// See docs/AGENTIC_FIRST_DESIGN.md §6.2. The dominant agent output cost is
// homogeneous record arrays (ls, proc.list, docker.ps, db queries); AECON emits
// the field names ONCE as a header, then positional TSV rows — typically a
// large output-token saving over repeating keys per row (as JSON does).
// ─────────────────────────────────────────────────────────────────────────
/// Render one cell value compactly. Strings are emitted raw unless empty or
/// containing the TSV delimiters (tab/newline), in which case they're JSON
/// quoted; nested arrays/records are inlined as JSON to preserve row structure.
fn aecon_atom(v: &Value) -> String {
match v {
Value::Str(s) => {
// A bare cell opening with `{` or `[` is reserved for a composite value
// (record/array), which renders as JSON below. A *string* that happens to
// start that way is JSON-quoted so the two can never be confused — the
// same reservation `""` already provides for the empty string.
if s.is_empty()
|| s.contains('\t')
|| s.contains('\n')
|| s.starts_with('{')
|| s.starts_with('[')
{
serde_json::to_string(s).unwrap_or_else(|_| format!("{:?}", s))
} else {
s.clone()
}
}
Value::Uri(u) => u.clone(),
Value::Int(n) => n.to_string(),
Value::Float(f) => f.to_string(),
Value::Bool(b) => b.to_string(),
Value::Null => "null".to_string(),
other => serde_json::to_string(&other.to_json()).unwrap_or_else(|_| "null".to_string()),
}
}
/// Longest common character prefix shared by every string in `vals` (empty if
/// they share none). Char-aligned, so the returned `String`'s byte length is a
/// valid UTF-8 slice boundary in each input — used by `@prefix` factoring.
fn longest_common_str_prefix(vals: &[&str]) -> String {
let mut prefix: Vec<char> = match vals.first() {
Some(s) => s.chars().collect(),
None => return String::new(),
};
for s in vals.iter().skip(1) {
let mut n = 0usize;
for (a, b) in prefix.iter().zip(s.chars()) {
if *a == b {
n += 1;
} else {
break;
}
}
prefix.truncate(n);
if prefix.is_empty() {
break;
}
}
prefix.into_iter().collect()
}
/// Longest common character *suffix* shared by every string in `vals` (empty if
/// they share none). Char-aligned, so subtracting the returned `String`'s byte
/// length from each input's length lands on a valid UTF-8 boundary — used by
/// `@suffix` factoring (shared extensions, domain tails, id suffixes).
fn longest_common_str_suffix(vals: &[&str]) -> String {
let mut suffix: Vec<char> = match vals.first() {
Some(s) => s.chars().rev().collect(),
None => return String::new(),
};
for s in vals.iter().skip(1) {
let mut n = 0usize;
for (a, b) in suffix.iter().zip(s.chars().rev()) {
if *a == b {
n += 1;
} else {
break;
}
}
suffix.truncate(n);
if suffix.is_empty() {
break;
}
}
suffix.into_iter().rev().collect()
}
/// Depth limit for `@nest` flattening. Deep enough for real API payloads, shallow
/// enough that a pathological or cyclic-looking shape cannot blow the stack.
const AECON_NEST_DEPTH: usize = 4;
/// Flatten a record's nested-record fields into dotted columns, in place.
/// `user: {id, name}` becomes `user.id` / `user.name`, recursively to
/// `AECON_NEST_DEPTH`. Records are the only thing expanded — arrays and scalars stay
/// whole, so a cell is always an atom. Returns the *top-level* keys that were
/// expanded, which the `@nest` line names so decode knows which dotted columns to
/// rebuild (and which merely contain a literal dot).
fn aecon_flatten_into(
src: &BTreeMap<String, Value>,
out: &mut BTreeMap<String, Value>,
prefix: &str,
depth: usize,
) {
for (k, v) in src {
let name = if prefix.is_empty() {
k.clone()
} else {
format!("{prefix}.{k}")
};
match v {
// An empty record carries no columns, so flattening it would erase the
// field entirely; keep it as an atom so the round-trip holds.
Value::Record(inner) if depth < AECON_NEST_DEPTH && !inner.is_empty() => {
aecon_flatten_into(inner, out, &name, depth + 1)
}
other => {
out.insert(name, other.clone());
}
}
}
}
/// Render a Value as AECON (IronShell Compact Object Notation). Tabular values
/// (Table, or a homogeneous Array<Record>) become a `@aecon` header + TSV rows;
/// single records become `{k=v ...}`; scalar arrays become `[a,b,c]`; scalars
/// render as their literal. Deterministic (records are key-sorted BTreeMaps).
fn aecon_render(v: &Value) -> String {
let (rows, header): (Vec<&BTreeMap<String, Value>>, Option<Vec<String>>) = match v {
Value::Table(t) => {
let h = if t.schema.is_empty() {
None
} else {
Some(t.schema.clone())
};
(t.rows.iter().collect(), h)
}
Value::Array(arr)
if !arr.is_empty() && arr.iter().all(|e| matches!(e, Value::Record(_))) =>
{
let rs = arr
.iter()
.filter_map(|e| match e {
Value::Record(m) => Some(m),
_ => None,
})
.collect();
(rs, None)
}
_ => (Vec::new(), None),
};
// `@nest`: a record-valued cell would otherwise serialize as whole JSON on every
// row — keys and all — which is precisely what this format exists to avoid, and
// none of the column passes below can reach inside it. Expanding it into dotted
// columns subjects each leaf to `@const`/`@dict`/`@prefix`/`@suffix`/`@same` like
// any other column. A top-level key is expanded only when no existing column
// already starts with `<key>.`, so a literal dotted key is never shadowed; the
// `@nest` line names exactly what was expanded, so decode rebuilds only those.
let mut nest_keys: Vec<String> = Vec::new();
let flattened: Option<Vec<BTreeMap<String, Value>>> = if rows.len() >= 2 {
let top: Vec<&String> = rows[0].keys().collect();
let expandable: Vec<String> = rows[0]
.iter()
.filter(|(k, v)| match v {
Value::Record(inner) => {
!inner.is_empty()
&& !top.iter().any(|other| other.starts_with(&format!("{k}.")))
}
_ => false,
})
.map(|(k, _)| k.clone())
.collect();
// Every row must agree that the field is a record, or the flattened table is
// ragged and the homogeneity check below would reject it anyway.
let consistent = expandable.iter().all(|k| {
rows.iter()
.all(|r| matches!(r.get(k), Some(Value::Record(m)) if !m.is_empty()))
});
if expandable.is_empty() || !consistent {
None
} else {
nest_keys = expandable;
Some(
rows.iter()
.map(|r| {
let mut out = BTreeMap::new();
for (k, v) in r.iter() {
if nest_keys.contains(k) {
if let Value::Record(inner) = v {
aecon_flatten_into(inner, &mut out, k, 1);
continue;
}
}
out.insert(k.clone(), v.clone());
}
out
})
.collect(),
)
}
} else {
None
};
let (rows, header) = match &flattened {
// Flattening rewrites the column set, so a caller-supplied schema no longer
// describes it; the expanded keys are the schema.
Some(f) => (f.iter().collect::<Vec<_>>(), None),
None => (rows, header),
};
if !rows.is_empty() {
let keys: Vec<String> = header.unwrap_or_else(|| rows[0].keys().cloned().collect());
let homogeneous = rows
.iter()
.all(|r| r.len() == keys.len() && keys.iter().all(|k| r.contains_key(k)));
if homogeneous {
// Constant-column factoring: columns whose value is identical across
// ALL rows are emitted once in a `@const` line rather than repeated
// per row — a real per-row token saving for the common case of
// constant status/type/owner fields. (No constants → unchanged.)
let (mut const_keys, mut var_keys): (Vec<String>, Vec<String>) =
(Vec::new(), Vec::new());
if rows.len() >= 2 {
for k in &keys {
let first = rows[0].get(k);
if rows.iter().all(|r| r.get(k) == first) {
const_keys.push(k.clone());
} else {
var_keys.push(k.clone());
}
}
} else {
var_keys = keys.clone();
}
// If every column is constant (identical rows), don't factor it all
// away — keep them as the header so the row count stays observable.
if var_keys.is_empty() {
var_keys = keys.clone();
const_keys.clear();
}
// Per-column encoding choice, decided by exact character cost.
//
// Every eligible STRING column has three candidate encodings — raw,
// `@dict` (each distinct value once, referenced per row by integer
// index), and `@prefix`/`@suffix` (shared leading and trailing runs
// emitted once and stripped from every row) — and the cheapest wins.
//
// Costing them for real replaces two hand-tuned dictionary heuristics
// (`d <= rows/2`, `avg_len >= 3`) that mis-fired in both directions:
// they declined dictionaries that would have paid on long values, and
// — because `@dict` was tried first and won by default — they took
// columns that prefix/suffix factoring would have compressed further.
// Costs are byte counts, so the choice is deterministic and
// format-stable across builds (no tokenizer, no float).
//
// Eligibility is bare-safe strings only (non-empty, no tab/newline).
// For `@prefix`/`@suffix` that keeps the stripped residue splittable;
// for `@dict` it is a *correctness* requirement, because the distinct
// values are emitted tab-separated on the `@dict` line — a value
// containing a tab would silently corrupt its own dictionary.
let mut dict_cols: std::collections::BTreeMap<String, Vec<String>> =
std::collections::BTreeMap::new();
let mut dict_index: std::collections::HashMap<
String,
std::collections::HashMap<String, usize>,
> = std::collections::HashMap::new();
let mut prefix_cols: std::collections::BTreeMap<String, String> =
std::collections::BTreeMap::new();
let mut suffix_cols: std::collections::BTreeMap<String, String> =
std::collections::BTreeMap::new();
for k in &var_keys {
let mut vals: Vec<&str> = Vec::with_capacity(rows.len());
let mut all_bare = true;
for r in &rows {
match r.get(k) {
Some(Value::Str(s))
if !s.is_empty() && !s.contains('\t') && !s.contains('\n') =>
{
vals.push(s.as_str())
}
_ => {
all_bare = false;
break;
}
}
}
if !all_bare {
continue;
}
// `@same` runs after this choice and elides any cell equal to the
// one above it, so a candidate must be costed as the later pass will
// actually leave it — otherwise a dictionary can win on paper and
// lose in the emitted output. Mirrors that pass's gate exactly,
// including the shapes it declines.
let same_eligible = rows.len() >= 4 && var_keys.len() >= 2;
let after_same = |cells: &[&str]| -> usize {
let total: usize = cells.iter().map(|c| c.len()).sum();
if !same_eligible {
return total;
}
let elidable: usize = (1..cells.len())
.filter(|&i| cells[i] == cells[i - 1])
.map(|i| cells[i].len())
.sum();
if elidable > k.len() + 1 {
total - elidable + k.len() + 1
} else {
total
}
};
let raw_cost = after_same(&vals);
// `@dict`: the distinct values once (tab-joined), plus one index
// per row. Only meaningful when something actually repeats.
let mut distinct: Vec<&str> = Vec::new();
let mut first_seen: std::collections::HashMap<&str, usize> =
std::collections::HashMap::new();
for v in &vals {
if !first_seen.contains_key(v) {
first_seen.insert(v, distinct.len());
distinct.push(v);
}
}
let dict_cost = if distinct.len() < vals.len() {
let indices: Vec<String> =
vals.iter().map(|v| first_seen[v].to_string()).collect();
let idx_refs: Vec<&str> = indices.iter().map(|s| s.as_str()).collect();
"\n@dict : ".len()
+ k.len()
+ distinct.iter().map(|d| d.len()).sum::<usize>()
+ distinct.len().saturating_sub(1)
+ after_same(&idx_refs)
} else {
usize::MAX
};
// `@prefix`/`@suffix`: each run is included only if emitting it once
// beats leaving it on every row. The suffix is searched in the
// residue the prefix leaves, so the two can never overlap — even on
// a value they consume entirely. `longest_common_str_prefix` is
// char-aligned, so slicing at its byte length is a valid boundary.
let n = vals.len();
let lcp = longest_common_str_prefix(&vals);
let p_line = "\n@prefix : ".len() + k.len() + lcp.len();
let lcp = if !lcp.is_empty() && lcp.len() * n > p_line {
lcp
} else {
String::new()
};
let residue: Vec<&str> = vals.iter().map(|s| &s[lcp.len()..]).collect();
let lcs = longest_common_str_suffix(&residue);
let s_line = "\n@suffix : ".len() + k.len() + lcs.len();
let lcs = if !lcs.is_empty() && lcs.len() * n > s_line {
lcs
} else {
String::new()
};
let ps_cost = if lcp.is_empty() && lcs.is_empty() {
usize::MAX
} else {
residue.iter().map(|s| s.len() - lcs.len()).sum::<usize>()
+ if lcp.is_empty() { 0 } else { p_line }
+ if lcs.is_empty() { 0 } else { s_line }
};
// Cheapest wins; comparisons are strict and ordered, so ties fall to
// the simpler encoding (raw, then prefix/suffix).
if dict_cost < raw_cost && dict_cost <= ps_cost {
let idx: std::collections::HashMap<String, usize> = distinct
.iter()
.enumerate()
.map(|(i, v)| ((*v).to_string(), i))
.collect();
dict_index.insert(k.clone(), idx);
dict_cols.insert(k.clone(), distinct.iter().map(|s| s.to_string()).collect());
} else if ps_cost < raw_cost {
if !lcp.is_empty() {
prefix_cols.insert(k.clone(), lcp);
}
if !lcs.is_empty() {
suffix_cols.insert(k.clone(), lcs);
}
}
}
// Delta encoding for large-valued, slowly-varying INTEGER columns
// (timestamps, sequential ids): row 0 carries the absolute value, each
// later row carries the difference from the previous row; a `@delta:`
// line names the affected columns. Gated to fire only where it's a real
// *token* win — the raw values must be multi-token (avg ≥4 decimal
// digits; small ints are one token regardless) and the deltas must be
// well under half the raw character count. Deltas use i128 (no
// overflow); never collides with `@dict` (string-only) as this is
// integer-only.
let mut delta_cols: std::collections::BTreeMap<String, Vec<String>> =
std::collections::BTreeMap::new();
if rows.len() >= 4 {
for k in &var_keys {
if dict_index.contains_key(k) {
continue;
}
let mut vals: Vec<i64> = Vec::with_capacity(rows.len());
let mut all_int = true;
for r in &rows {
match r.get(k) {
Some(Value::Int(n)) => vals.push(*n),
_ => {
all_int = false;
break;
}
}
}
if !all_int {
continue;
}
let raw_chars: usize = vals.iter().map(|v| v.to_string().len()).sum();
if raw_chars / vals.len() < 4 {
continue; // raws aren't multi-token — a delta saves nothing
}
let mut cells: Vec<String> = Vec::with_capacity(vals.len());
cells.push(vals[0].to_string());
let mut delta_chars = cells[0].len();
for i in 1..vals.len() {
let s = ((vals[i] as i128) - (vals[i - 1] as i128)).to_string();
delta_chars += s.len();
cells.push(s);
}
if delta_chars * 2 < raw_chars {
delta_cols.insert(k.clone(), cells);
}
}
}
// Type tags for lossless decode (reliability): emitted only for the
// columns where the compact form would be misread by inference — a
// string that looks like a number/bool/null, or an integral float.
// Dict columns are always strings and delta columns always integers,
// so they never need a tag; this costs tokens only on real ambiguity.
let mut type_tags: std::collections::BTreeMap<String, char> =
std::collections::BTreeMap::new();
for k in const_keys.iter().chain(var_keys.iter()) {
if dict_index.contains_key(k)
|| delta_cols.contains_key(k)
|| prefix_cols.contains_key(k)
|| suffix_cols.contains_key(k)
{
continue;
}
let vals: Vec<&Value> = if const_keys.contains(k) {
vec![rows[0].get(k).unwrap_or(&Value::Null)]
} else {
rows.iter()
.map(|r| r.get(k).unwrap_or(&Value::Null))
.collect()
};
if let Some(tag) = aecon_type_tag(&vals) {
type_tags.insert(k.clone(), tag);
}
}
// Render every cell once, so repeat-elision below can be gated on the
// characters it actually saves rather than on a guess.
let mut matrix: Vec<Vec<String>> = rows
.iter()
.enumerate()
.map(|(ri, r)| {
var_keys
.iter()
.map(|k| {
if let Some(col) = delta_cols.get(k) {
return col[ri].clone();
}
let v = r.get(k).unwrap_or(&Value::Null);
if let Value::Str(s) = v {
let p = prefix_cols.get(k).map_or(0, |p| p.len());
let sfx = suffix_cols.get(k).map_or(0, |x| x.len());
if p > 0 || sfx > 0 {
// bare-safe and non-overlapping by construction.
return s[p..s.len() - sfx].to_string();
}
}
match (dict_index.get(k), v) {
(Some(idx), Value::Str(s)) => idx
.get(s)
.map(|i| i.to_string())
.unwrap_or_else(|| aecon_atom(v)),
_ => aecon_atom(v),
}
})
.collect()
})
.collect();
// Repeat elision (`@same`): in a column that runs in blocks — the shape
// of any sorted or grouped result — a cell identical to the one above it
// is emitted as *nothing*. An empty cell is an unambiguous sentinel
// because a genuinely empty string renders JSON-quoted (`""`). `@const`
// already covers columns constant across every row; this covers the runs
// `@const` cannot see. Excluded: `@delta` columns (whose running sum owns
// the previous-row state), `@prefix`/`@suffix` columns (whose stripped
// residue may legitimately be empty), and single-column tables — where
// an all-elided row renders as a bare empty line. The decoder now reads
// interior blank lines as rows, so that last exclusion is defence in
// depth rather than the sole guard; it costs nothing, because a
// single-column run is already `@dict`'s case.
let mut same_cols: Vec<String> = Vec::new();
if rows.len() >= 4 && var_keys.len() >= 2 {
for (ci, k) in var_keys.iter().enumerate() {
if delta_cols.contains_key(k)
|| prefix_cols.contains_key(k)
|| suffix_cols.contains_key(k)
{
continue;
}
let saved: usize = (1..matrix.len())
.filter(|&ri| matrix[ri][ci] == matrix[ri - 1][ci])
.map(|ri| matrix[ri][ci].len())
.sum();
// Naming a column costs a tab plus its name; the `\n@same `
// marker is charged once, to the set, below.
if saved > k.len() + 1 {
same_cols.push(k.clone());
}
}
// The line only pays if the set as a whole beats its own overhead.
let set_saved: usize = same_cols
.iter()
.filter_map(|k| var_keys.iter().position(|v| v == k))
.map(|ci| {
(1..matrix.len())
.filter(|&ri| matrix[ri][ci] == matrix[ri - 1][ci])
.map(|ri| matrix[ri][ci].len())
.sum::<usize>()
})
.sum();
let set_cost: usize =
"\n@same ".len() + same_cols.iter().map(|k| k.len() + 1).sum::<usize>();
if set_saved <= set_cost {
same_cols.clear();
}
// Reverse order: each comparison must see the previous row's
// *un-elided* cell.
for ci in same_cols
.iter()
.filter_map(|k| var_keys.iter().position(|v| v == k))
.collect::<Vec<_>>()
{
for ri in (1..matrix.len()).rev() {
if matrix[ri][ci] == matrix[ri - 1][ci] {
matrix[ri][ci].clear();
}
}
}
}
// Tight format: a bare tab-separated header line (the column schema),
// optional `@const` (factored constants), `@dict` (low-cardinality
// columns), `@prefix`/`@suffix` (shared leading/trailing runs), `@same`
// (repeat-elided columns), `@delta:` (delta-encoded numeric columns) and
// `@type` (lossless type tags) metadata lines, then positional
// tab-separated rows. No `@aecon rows=N cols=` prefix — the header is
// self-describing and ~7 tokens cheaper per result than the verbose form.
let mut out = var_keys.join("\t");
if !nest_keys.is_empty() {
out.push_str("\n@nest ");
out.push_str(&nest_keys.join("\t"));
}
if !const_keys.is_empty() {
let consts: Vec<String> = const_keys
.iter()
.map(|k| {
format!(
"{}={}",
k,
aecon_atom(rows[0].get(k).unwrap_or(&Value::Null))
)
})
.collect();
out.push_str("\n@const ");
out.push_str(&consts.join("\t"));
}
for (col, distinct) in &dict_cols {
out.push_str(&format!("\n@dict {}: ", col));
out.push_str(&distinct.join("\t"));
}
for (col, prefix) in &prefix_cols {
out.push_str(&format!("\n@prefix {}: {}", col, prefix));
}
for (col, suffix) in &suffix_cols {
out.push_str(&format!("\n@suffix {}: {}", col, suffix));
}
if !same_cols.is_empty() {
out.push_str("\n@same ");
out.push_str(&same_cols.join("\t"));
}
if !delta_cols.is_empty() {
out.push_str("\n@delta: ");
let names: Vec<&str> = delta_cols.keys().map(|s| s.as_str()).collect();
out.push_str(&names.join("\t"));
}
if !type_tags.is_empty() {
out.push_str("\n@type ");
let tags: Vec<String> = type_tags
.iter()
.map(|(k, t)| format!("{}:{}", k, t))
.collect();
out.push_str(&tags.join("\t"));
}
for cells in &matrix {
out.push('\n');
out.push_str(&cells.join("\t"));
}
return out;
}
}
match v {
Value::Record(m) => {
let pairs: Vec<String> = m
.iter()
.map(|(k, val)| format!("{}={}", k, aecon_atom(val)))
.collect();
format!("{{{}}}", pairs.join(" "))
}
Value::Array(arr) => {
let items: Vec<String> = arr.iter().map(aecon_atom).collect();
format!("[{}]", items.join(","))
}
other => aecon_atom(other),
}
}
/// Whether a bare string would be misread by `aecon_decode_atom` as a non-string
/// (number/bool/null) — i.e. it needs an explicit `s` type tag for lossless decode.
fn aecon_str_is_ambiguous(s: &str) -> bool {
s == "null"
|| s == "true"
|| s == "false"
|| s.parse::<i64>().is_ok()
|| s.parse::<f64>().is_ok()
}
/// The type tag a column needs for lossless decode, or `None` if inference is
/// already exact. Only two cases are ambiguous in the compact form: an all-string
/// column containing a number/bool/null-looking value (`s`), and an all-float
/// column containing an integral value that renders without a `.` (`f`, which
/// inference would read back as `Int`). Mixed-variant columns return `None`
/// (best-effort — use `canonical` for those).
fn aecon_type_tag(vals: &[&Value]) -> Option<char> {
if vals.is_empty() {
return None;
}
if vals.iter().all(|v| matches!(v, Value::Str(_))) {
let ambiguous = vals.iter().any(|v| match v {
// Empty / tab / newline strings render JSON-quoted and decode exactly,
// so only *bare* ambiguous strings need the tag.
Value::Str(s) => {
!s.is_empty() && !s.contains('\t') && !s.contains('\n') && aecon_str_is_ambiguous(s)
}
_ => false,
});
return ambiguous.then_some('s');
}
if vals.iter().all(|v| matches!(v, Value::Float(_))) {
let ambiguous = vals.iter().any(|v| match v {
Value::Float(f) => f.is_finite() && f.fract() == 0.0,
_ => false,
});
return ambiguous.then_some('f');
}
None
}
/// Decode a cell honoring an explicit type tag (`s` → string, `f` → float),
/// falling back to inference when there is no tag.
fn aecon_decode_atom_typed(cell: &str, tag: Option<&char>) -> Value {
match tag {
Some('s') => {
if cell.starts_with('"') {
serde_json::from_str::<String>(cell)
.map(Value::Str)
.unwrap_or_else(|_| Value::Str(cell.to_string()))
} else {
Value::Str(cell.to_string())
}
}
Some('f') => cell
.parse::<f64>()
.map(Value::Float)
.unwrap_or_else(|_| aecon_decode_atom(cell)),
_ => aecon_decode_atom(cell),
}
}
/// Reverse `aecon_atom`: best-effort typed reconstruction of a single cell.
/// JSON-quoted cells (empty/tab/newline strings) are decoded exactly; otherwise
/// `null`/`true`/`false`/integer/float are recognized, falling back to a bare
/// string. The string↔number boundary is inferred (a string `"123"` decodes as
/// `Int`), exactly like CSV — for lossless typing use `canonical` (JSON).
fn aecon_decode_atom(cell: &str) -> Value {
if cell.starts_with('"') {
if let Ok(s) = serde_json::from_str::<String>(cell) {
return Value::Str(s);
}
}
// Composite values render as bare JSON, and `aecon_atom` quotes any *string*
// that would open the same way, so a leading `{`/`[` unambiguously means one.
if cell.starts_with('{') || cell.starts_with('[') {
if let Ok(j) = serde_json::from_str::<serde_json::Value>(cell) {
return Value::from_json(&j);
}
}
match cell {
"null" => return Value::Null,
"true" => return Value::Bool(true),
"false" => return Value::Bool(false),
_ => {}
}
if let Ok(i) = cell.parse::<i64>() {
return Value::Int(i);
}
if let Ok(f) = cell.parse::<f64>() {
return Value::Float(f);
}
Value::Str(cell.to_string())
}
/// Rebuild the nested records that `@nest` flattened away. Only columns whose first
/// dotted segment is named in `nest` are rebuilt — every other key is kept verbatim,
/// so a column that merely *contains* a dot is never mistaken for a nested field.
fn aecon_unflatten(
flat: BTreeMap<String, Value>,
nest: &std::collections::HashSet<String>,
) -> BTreeMap<String, Value> {
let mut out: BTreeMap<String, Value> = BTreeMap::new();
for (k, v) in flat {
let head = k.split('.').next().unwrap_or("");
if !nest.contains(head) || !k.contains('.') {
out.insert(k, v);
continue;
}
let segments: Vec<&str> = k.split('.').collect();
// Walk down, creating records as needed, and place the leaf at the bottom.
let mut cursor = &mut out;
for seg in &segments[..segments.len() - 1] {
let entry = cursor
.entry((*seg).to_string())
.or_insert_with(|| Value::Record(BTreeMap::new()));
if !matches!(entry, Value::Record(_)) {
*entry = Value::Record(BTreeMap::new());
}
cursor = match entry {
Value::Record(m) => m,
_ => unreachable!("just forced to a record"),
};
}
cursor.insert(segments[segments.len() - 1].to_string(), v);
}
out
}
/// Decode the tabular AECON form (header + optional `@const`/`@dict`/`@delta`/
/// `@prefix`/`@suffix`/`@same`/`@type` metadata lines + positional rows) back to an
/// `Array<Record>`.
/// Inverse of the tabular branch of `aecon_render`: `@const` columns are restored to
/// every row, `@dict` indices are resolved to their string values, `@delta` columns
/// are reconstructed by running sum, `@prefix`/`@suffix` columns get their shared
/// runs re-attached, `@same` columns resolve an empty cell to the row above, and
/// `@type` tags force the exact type of columns
/// the compact form would otherwise infer wrongly (numeric-looking strings,
/// integral floats) — making the round-trip lossless. Returns the parsed array;
/// non-tabular input (a single `{k=v}` record, a scalar array, or a bare scalar)
/// is returned as a best-effort single value. A `@delta` cell that is not an
/// integer is refused: read as 0, it shifted every later row of its column.
fn aecon_decode(s: &str) -> Result<Value> {
let lines: Vec<&str> = s.split('\n').collect();
if lines.is_empty() {
return Ok(Value::Null);
}
let header = lines[0];
// Non-tabular fallbacks: a single record / scalar array / bare scalar.
if !header.contains('\t') && (header.starts_with('{') || header.starts_with('[')) {
return Ok(aecon_decode_atom(header));
}
let var_keys: Vec<String> = header.split('\t').map(|s| s.to_string()).collect();
// `@const` is decoded *after* the metadata loop because `@type` (which can
// correct a const's inferred type) is emitted after it; collect the raw
// key=value pairs first, then apply tags.
let mut const_raw: Vec<(String, String)> = Vec::new();
let mut dicts: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
let mut prefixes: std::collections::HashMap<String, String> = std::collections::HashMap::new();
let mut suffixes: std::collections::HashMap<String, String> = std::collections::HashMap::new();
let mut same_set: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut nest_set: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut delta_set: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut type_tags: std::collections::HashMap<String, char> = std::collections::HashMap::new();
let mut row_start = 1;
for (i, line) in lines.iter().enumerate().skip(1) {
if let Some(rest) = line.strip_prefix("@const ") {
for kv in rest.split('\t') {
if let Some((k, v)) = kv.split_once('=') {
const_raw.push((k.to_string(), v.to_string()));
}
}
} else if let Some(rest) = line.strip_prefix("@dict ") {
if let Some((col, vals)) = rest.split_once(": ") {
dicts.insert(
col.to_string(),
vals.split('\t').map(|s| s.to_string()).collect(),
);
}
} else if let Some(rest) = line.strip_prefix("@prefix ") {
if let Some((col, p)) = rest.split_once(": ") {
prefixes.insert(col.to_string(), p.to_string());
}
} else if let Some(rest) = line.strip_prefix("@suffix ") {
if let Some((col, p)) = rest.split_once(": ") {
suffixes.insert(col.to_string(), p.to_string());
}
} else if let Some(rest) = line.strip_prefix("@same ") {
for c in rest.split('\t') {
same_set.insert(c.to_string());
}
} else if let Some(rest) = line.strip_prefix("@nest ") {
for c in rest.split('\t') {
nest_set.insert(c.to_string());
}
} else if let Some(rest) = line.strip_prefix("@delta: ") {
for c in rest.split('\t') {
delta_set.insert(c.to_string());
}
} else if let Some(rest) = line.strip_prefix("@type ") {
for spec in rest.split('\t') {
if let Some((col, tag)) = spec.rsplit_once(':') {
if let Some(c) = tag.chars().next() {
type_tags.insert(col.to_string(), c);
}
}
}
} else {
row_start = i;
break;
}
row_start = i + 1;
}
let consts: Vec<(String, Value)> = const_raw
.iter()
.map(|(k, v)| (k.clone(), aecon_decode_atom_typed(v, type_tags.get(k))))
.collect();
// Running absolute value per delta column, advanced as rows are read in order.
let mut delta_run: std::collections::HashMap<String, i128> = std::collections::HashMap::new();
// Last emitted value per `@same` column, so an elided (empty) cell resolves to
// the row above it.
let mut prev_same: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
let mut out_rows: Vec<Value> = Vec::new();
// Trailing blank lines are formatting; an *interior* blank line is a real row
// whose every cell was factored away — a single-column table whose value equals
// its `@prefix`+`@suffix` renders exactly that way. Trim the former so the
// latter is not silently dropped.
let mut row_end = lines.len();
while row_end > row_start && lines[row_end - 1].is_empty() {
row_end -= 1;
}
for line in &lines[row_start..row_end] {
let cells: Vec<&str> = line.split('\t').collect();
let mut m = BTreeMap::new();
for (ci, k) in var_keys.iter().enumerate() {
let cell = cells.get(ci).copied().unwrap_or("");
let val = if same_set.contains(k) && cell.is_empty() && prev_same.contains_key(k) {
prev_same[k].clone()
} else if delta_set.contains(k) {
let n: i128 = cell.parse().map_err(|_| {
crate::safety::bad_arg(
"aecon_decode",
&format!("an integer in @delta column {k}"),
cell,
)
})?;
let abs = match delta_run.get(k) {
Some(prev) => prev + n, // later rows carry the difference
None => n, // row 0 carries the absolute value
};
delta_run.insert(k.clone(), abs);
let abs = i64::try_from(abs).map_err(|_| {
crate::safety::bad_arg(
"aecon_decode",
&format!("@delta column {k} to stay within Int"),
&abs.to_string(),
)
})?;
Value::Int(abs)
} else if let Some(values) = dicts.get(k) {
match cell.parse::<usize>().ok().and_then(|i| values.get(i)) {
Some(s) => Value::Str(s.clone()),
None => aecon_decode_atom(cell),
}
} else if prefixes.contains_key(k) || suffixes.contains_key(k) {
let prefix = prefixes.get(k).map(String::as_str).unwrap_or("");
let suffix = suffixes.get(k).map(String::as_str).unwrap_or("");
Value::Str(format!("{prefix}{cell}{suffix}"))
} else {
aecon_decode_atom_typed(cell, type_tags.get(k))
};
if same_set.contains(k) {
prev_same.insert(k.clone(), val.clone());
}
m.insert(k.clone(), val);
}
for (k, v) in &consts {
m.insert(k.clone(), v.clone());
}
out_rows.push(Value::Record(if nest_set.is_empty() {
m
} else {
aecon_unflatten(m, &nest_set)
}));
}
Ok(Value::Array(out_rows))
}
/// Token count used by the token-economy builtins (`tokens`/`budget`/`digest`)
/// and the Agent API accounting. With `--features real-tokens` this is the
/// **real GPT-4 cl100k BPE** (vocab embedded in `tiktoken-rs`); otherwise a
/// labeled heuristic (alphanumeric run ≈ ceil(len/4) tokens; each symbol ≈ 1).
#[cfg(feature = "real-tokens")]
pub fn est_token_count(s: &str) -> usize {
use std::sync::OnceLock;
static BPE: OnceLock<tiktoken_rs::CoreBPE> = OnceLock::new();
let bpe = BPE.get_or_init(|| tiktoken_rs::cl100k_base().expect("load cl100k_base"));
bpe.encode_with_special_tokens(s).len()
}
#[cfg(not(feature = "real-tokens"))]
pub fn est_token_count(s: &str) -> usize {
let mut toks = 0usize;
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c.is_whitespace() {
continue;
}
if c.is_alphanumeric() || c == '_' {
let mut len = 1usize;
while let Some(&n) = chars.peek() {
if n.is_alphanumeric() || n == '_' {
len += 1;
chars.next();
} else {
break;
}
}
toks += len.div_ceil(4).max(1);
} else {
toks += 1;
}
}
toks
}
/// aecon(value?) - Render a value (argument or piped input) as compact AECON
/// text. Saves output tokens on homogeneous record arrays by emitting keys once.
fn bi_aecon(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let v = args.into_iter().next().or(input).ok_or_else(|| {
crate::safety::bad_arg("aecon", "a value (argument or piped input)", "nothing")
})?;
Ok(Value::Str(aecon_render(&v)))
}
/// aecon_decode(text?) - Parse tabular AECON text (argument or piped input) back
/// into an `Array<Record>`, reversing `@const`/`@dict`/`@delta`/`@prefix`/`@suffix`/
/// `@same` factoring.
/// The inverse of `aecon` for homogeneous tables; the string↔number boundary is
/// inferred (use `canonical` for lossless typing).
fn bi_aecon_decode(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let v = args.into_iter().next().or(input).ok_or_else(|| {
crate::safety::bad_arg(
"aecon_decode",
"AECON text (argument or piped input)",
"nothing",
)
})?;
let s = match v {
Value::Str(s) => s,
other => {
return Err(crate::safety::bad_arg(
"aecon_decode",
"a String of AECON text",
&format!("{:?}", other),
))
}
};
aecon_decode(&s)
}
/// tokens(value?) - Estimate the token count of a value's rendered form (its
/// string content if a String, else its display form). Pair with `aecon` to
/// compare output costs, e.g. `tokens(v)` vs `tokens(aecon(v))`.
fn bi_tokens(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let v = args.into_iter().next().or(input).ok_or_else(|| {
crate::safety::bad_arg("tokens", "a value (argument or piped input)", "nothing")
})?;
let s = match &v {
Value::Str(s) => s.clone(),
other => other.to_display_string(),
};
Ok(Value::Int(est_token_count(&s) as i64))
}
/// A compact structural type descriptor for a value: a Record maps each key to
/// its type name; an Array reports its element type; a scalar reports its type.
/// Lets `digest` convey shape in a handful of tokens.
fn value_shape(v: &Value) -> Value {
match v {
Value::Record(m) => Value::Record(
m.iter()
.map(|(k, val)| (k.clone(), Value::Str(val.type_name().to_string())))
.collect(),
),
Value::Array(a) => Value::Str(format!(
"Array<{}>",
a.first().map(|e| e.type_name()).unwrap_or("Any")
)),
other => Value::Str(other.type_name().to_string()),
}
}
/// digest(value?) - A constant-token structural summary of a value: its kind,
/// size, element/field shape, a 2-element sample, and the token cost of the full
/// value (default render vs compact AECON). Lets an agent understand a large
/// result's shape and size cheaply, then decide whether to page (`budget`),
/// compact (`aecon`), or skip it — maximal information per token.
fn bi_digest(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let v = args.into_iter().next().or(input).ok_or_else(|| {
crate::safety::bad_arg("digest", "a value (argument or piped input)", "nothing")
})?;
let mut r = BTreeMap::new();
match &v {
Value::Array(a) => {
r.insert("kind".to_string(), Value::Str("array".to_string()));
r.insert("len".to_string(), Value::Int(a.len() as i64));
if let Some(first) = a.first() {
r.insert("element".to_string(), value_shape(first));
}
r.insert(
"sample".to_string(),
Value::Array(a.iter().take(2).cloned().collect()),
);
}
Value::Table(t) => {
r.insert("kind".to_string(), Value::Str("table".to_string()));
r.insert("rows".to_string(), Value::Int(t.rows.len() as i64));
r.insert(
"cols".to_string(),
Value::Array(t.schema.iter().map(|c| Value::Str(c.clone())).collect()),
);
r.insert(
"sample".to_string(),
Value::Array(
t.rows
.iter()
.take(2)
.map(|row| Value::Record(row.clone()))
.collect(),
),
);
}
Value::Record(m) => {
r.insert("kind".to_string(), Value::Str("record".to_string()));
r.insert("keys".to_string(), Value::Int(m.len() as i64));
r.insert("shape".to_string(), value_shape(&v));
}
Value::Str(s) => {
r.insert("kind".to_string(), Value::Str("string".to_string()));
r.insert("len".to_string(), Value::Int(s.chars().count() as i64));
r.insert(
"preview".to_string(),
Value::Str(s.chars().take(60).collect::<String>()),
);
}
other => {
r.insert(
"kind".to_string(),
Value::Str(other.type_name().to_string()),
);
r.insert("value".to_string(), other.clone());
}
}
r.insert(
"full_tokens".to_string(),
Value::Int(est_token_count(&v.to_display_string()) as i64),
);
r.insert(
"compact_tokens".to_string(),
Value::Int(est_token_count(&aecon_render(&v)) as i64),
);
Ok(Value::Record(r))
}
/// Project a record to only the named fields (in the given order, skipping
/// fields that don't exist).
fn pick_record(m: &BTreeMap<String, Value>, fields: &[String]) -> Value {
let mut out = BTreeMap::new();
for f in fields {
if let Some(v) = m.get(f) {
out.insert(f.clone(), v.clone());
}
}
Value::Record(out)
}
/// pick(value?, fields...) - Source-side projection: keep only the named fields
/// of each record (an array of records, a single record, or a table), dropping
/// the rest *before* rendering. The biggest output-token saver — pair with
/// `aecon`: `data | pick("name", "size") | aecon`. With piped input the args are
/// the field names; otherwise the first arg is the value.
fn bi_pick(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let collect_fields = |vals: &[Value]| -> Vec<String> {
vals.iter()
.filter_map(|a| match a {
Value::Str(s) => Some(s.clone()),
_ => None,
})
.collect()
};
let (value, fields) = if let Some(inp) = input {
(inp, collect_fields(&args))
} else {
let value = args
.first()
.cloned()
.ok_or_else(|| crate::safety::bad_arg("pick", "value, fields: String...", "nothing"))?;
(value, collect_fields(&args[1..]))
};
if fields.is_empty() {
return Err(crate::safety::bad_arg(
"pick",
"at least one field name",
"none",
));
}
let result = match value {
Value::Array(a) => Value::Array(
a.iter()
.map(|e| match e {
Value::Record(m) => pick_record(m, &fields),
other => other.clone(),
})
.collect(),
),
Value::Record(m) => pick_record(&m, &fields),
Value::Table(t) => Value::Array(t.rows.iter().map(|m| pick_record(m, &fields)).collect()),
other => other,
};
Ok(result)
}
/// JSON-escape and quote a string (delegates to serde_json for correctness).
fn json_quote(s: &str) -> String {
serde_json::to_string(s).unwrap_or_else(|_| format!("{:?}", s))
}
/// Write a Value as canonical JSON: deterministic and byte-stable across
/// platforms/locale. Record keys are emitted in sorted order (Records are
/// BTreeMaps); floats use Rust's shortest round-trip, locale-independent
/// formatting; non-finite floats and non-serializable values (lambdas/futures)
/// become explicit sentinels so output never varies or fails.
fn canonical_write(v: &Value, out: &mut String) {
match v {
Value::Null => out.push_str("null"),
Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
Value::Int(n) => out.push_str(&n.to_string()),
Value::Float(f) => {
if f.is_finite() {
out.push_str(&format!("{}", f));
} else {
out.push_str("null"); // NaN/Inf are not representable in JSON
}
}
Value::Str(s) | Value::Uri(s) => out.push_str(&json_quote(s)),
Value::Array(a) => {
out.push('[');
for (i, e) in a.iter().enumerate() {
if i > 0 {
out.push(',');
}
canonical_write(e, out);
}
out.push(']');
}
Value::Record(m) => {
out.push('{');
for (i, (k, val)) in m.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&json_quote(k));
out.push(':');
canonical_write(val, out);
}
out.push('}');
}
Value::Table(t) => {
// Canonical object with keys in sorted order: "rows" then "schema".
out.push_str("{\"rows\":[");
for (i, r) in t.rows.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push('{');
for (j, (k, val)) in r.iter().enumerate() {
if j > 0 {
out.push(',');
}
out.push_str(&json_quote(k));
out.push(':');
canonical_write(val, out);
}
out.push('}');
}
out.push_str("],\"schema\":[");
for (i, c) in t.schema.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&json_quote(c));
}
out.push_str("]}");
}
Value::Lambda(_) | Value::AsyncLambda(_) | Value::Future(_) => out.push_str("null"),
Value::Error(msg) => {
out.push_str("{\"error\":");
out.push_str(&json_quote(msg));
out.push('}');
}
Value::Builtin(b) => {
out.push_str("{\"builtin\":");
out.push_str(&json_quote(&b.name));
out.push('}');
}
}
}
fn canonical_render(v: &Value) -> String {
let mut out = String::new();
canonical_write(v, &mut out);
out
}
/// canonical(value?) - Render a value as canonical JSON: deterministic and
/// byte-identical for equal values across OS/locale (sorted keys, shortest
/// round-trip floats, explicit null for non-finite/non-serializable). The
/// lossless counterpart to `aecon`; the basis for reproducible output/diffs.
fn bi_canonical(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let v = args.into_iter().next().or(input).ok_or_else(|| {
crate::safety::bad_arg("canonical", "a value (argument or piped input)", "nothing")
})?;
Ok(Value::Str(canonical_render(&v)))
}
/// Budget a rendered string to `max` estimated tokens, never silently lossy:
/// over budget → keep a prefix and append an explicit elision marker.
fn budget_string_record(s: &str, max: usize) -> Value {
let total = est_token_count(s);
let mut r = BTreeMap::new();
if total <= max {
r.insert("page".to_string(), Value::Str(s.to_string()));
r.insert("truncated".to_string(), Value::Bool(false));
r.insert("page_tokens".to_string(), Value::Int(total as i64));
r.insert("total_tokens".to_string(), Value::Int(total as i64));
r.insert("elided_chars".to_string(), Value::Int(0));
} else {
let keep = max.saturating_mul(4); // ~4 chars/token heuristic
let prefix: String = s.chars().take(keep).collect();
let elided = s.chars().count().saturating_sub(keep);
let page = format!("{}…({} more chars elided)", prefix, elided);
let page_tokens = est_token_count(&page);
r.insert("page".to_string(), Value::Str(page));
r.insert("truncated".to_string(), Value::Bool(true));
r.insert("page_tokens".to_string(), Value::Int(page_tokens as i64));
r.insert("total_tokens".to_string(), Value::Int(total as i64));
r.insert("elided_chars".to_string(), Value::Int(elided as i64));
}
Value::Record(r)
}
/// budget(value?, max_tokens, cursor?) - Render a value to fit within an
/// estimated token budget, never silently lossy. For arrays/tables it returns a
/// page of rows starting at `cursor` plus paging metadata (`next_cursor`,
/// `elided`, `total`, `page_tokens`); for other values it truncates the AECON
/// rendering with an explicit elision marker. With piped input the args are
/// `(max_tokens, cursor?)`; otherwise `(value, max_tokens, cursor?)`.
fn bi_budget(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let mut args = args.into_iter();
let value = input
.or_else(|| args.next())
.ok_or_else(|| crate::safety::arg_err("budget requires value and max_tokens"))?;
let max = rl_positive_size(
"budget max_tokens",
&args
.next()
.ok_or_else(|| crate::safety::arg_err("budget requires max_tokens"))?,
)?;
let cursor = match args.next() {
None => 0,
Some(Value::Int(n)) => usize::try_from(n).map_err(|_| {
crate::safety::arg_err("budget cursor must be a nonnegative representable Int")
})?,
Some(_) => {
return Err(crate::safety::arg_err(
"budget cursor must be a nonnegative Int",
))
}
};
if !matches!(&value, Value::Array(_) | Value::Table(_)) && cursor != 0 {
return Err(crate::safety::arg_err(
"budget nonzero cursor requires Array or Table",
));
}
Ok(budget_value(&value, max, cursor))
}
/// Core of `budget`: render `value` to fit ~`max` estimated tokens, paging
/// arrays/tables from `cursor` (with metadata) and truncating other values'
/// AECON rendering losslessly. Public so the REPL can apply `IRONSHELL_TOKEN_BUDGET`.
pub fn budget_value(value: &Value, max: usize, cursor: usize) -> Value {
let total = match value {
Value::Table(t) => t.rows.len(),
Value::Array(a) => a.len(),
other => return budget_string_record(&aecon_render(other), max),
};
let start = cursor.min(total);
let mut end = start;
let mut used = 0usize;
while end < total {
// Conservative per-row estimate (a single record renders with its keys;
// the final page shares one header, so this never under-counts).
let rendered = match value {
Value::Table(t) => aecon_render(&Value::Record(t.rows[end].clone())),
Value::Array(a) => aecon_render(&a[end]),
_ => unreachable!("row value was checked"),
};
let row_tok = est_token_count(&rendered);
if end > start && (used > max || row_tok > max.saturating_sub(used)) {
break;
}
used = used.saturating_add(row_tok);
end += 1;
}
if end == start && total > start {
end = start + 1; // always make progress, even on a single oversized row
}
let page_slice = Value::Array(match value {
Value::Table(t) => t.rows[start..end]
.iter()
.cloned()
.map(Value::Record)
.collect(),
Value::Array(a) => a[start..end].to_vec(),
_ => unreachable!("row value was checked"),
});
let page_str = aecon_render(&page_slice);
let page_tokens = est_token_count(&page_str);
let shown = end - start;
let mut r = BTreeMap::new();
r.insert("page".to_string(), Value::Str(page_str));
r.insert("shown".to_string(), Value::Int(shown as i64));
r.insert("from".to_string(), Value::Int(start as i64));
r.insert("total".to_string(), Value::Int(total as i64));
r.insert("elided".to_string(), Value::Int((total - end) as i64));
r.insert("page_tokens".to_string(), Value::Int(page_tokens as i64));
r.insert(
"next_cursor".to_string(),
if end < total {
Value::Int(end as i64)
} else {
Value::Null
},
);
r.insert(
"truncated".to_string(),
Value::Bool(end < total || start > 0),
);
Value::Record(r)
}
/// Render a result for **agent mode** (`IRONSHELL_MODE=agent`): compact, deterministic
/// AECON instead of the human pretty-printer (no ANSI, keys emitted once, the
/// four structural levers applied). `Null` prints nothing and a bare `Str` is
/// returned raw (a single field value is what the agent asked for). When a token
/// budget is set, the value is paged via `budget_value` and rendered as the AECON
/// page followed by one compact `@page …` metadata line (`shown`/`total`/`elided`/
/// `next_cursor`/…), avoiding the double-encoding that pretty-printing the
/// envelope record would cause.
pub fn render_agent(v: &Value, budget: Option<usize>) -> Option<String> {
// Scrub secret shapes from the value before it enters the agent's context
// window (§7.6). Agent-path only — the human pretty-printer keeps full
// fidelity. Opt out with IRONSHELL_REDACT=off.
// Owned when redacted, so a handle can take it without a copy. With
// redaction off the caller's value is borrowed and a handle copies it once.
let v = if crate::safety::redaction_enabled() {
std::borrow::Cow::Owned(redact_value(v))
} else {
std::borrow::Cow::Borrowed(v)
};
match v.as_ref() {
Value::Null => None,
Value::Str(s) => Some(s.clone()),
_ => {
let full = match budget {
Some(max) if max > 0 => render_budget_envelope(&budget_value(v.as_ref(), max, 0)),
_ => aecon_render(v.as_ref()),
};
// A result too large to be worth sending whole is kept server-side
// and referenced instead. The agent narrows it down with a pipeline
// and only the small answer crosses back — the saving no encoder can
// reach, because it is the bytes never sent.
if crate::handles::worth_handling(v.as_ref(), full.len()) {
match render_handle(v.into_owned(), full.len()) {
Ok(handle) => Some(handle),
// At the handle cap the result is not sent: it is over the
// budget, which is why it was being handled. The refusal says
// so and how large it was, rather than flooding the output.
Err(e) => Some(format!(
"@handle refused: {e}\nthe result ({} bytes) is over the output budget and was not shown; release a handle with handle_drop(id) and run it again",
full.len()
)),
}
} else {
Some(full)
}
}
}
}
/// `undo(n = 1)` — put back what the last `n` mutating steps overwrote.
///
/// Returns one record per step, **including the ones it could not reverse**.
/// The counts are reported separately because "3 restored" and "3 restored, 1
/// irreversible" call for different decisions, and collapsing them is how a
/// partial recovery gets mistaken for a complete one.
// Each refuses pipeline input in its body rather than in its dispatch arm:
// tests/fallback_dispatch.rs reads the arms as `name => bi_fn(..)` pairs.
fn bi_undo(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_pipeline_input("undo", &input)?;
let n = match args.first() {
None => 1,
Some(v) => v
.as_int()
.map_err(|_| crate::safety::bad_arg("undo", "a step count", "a non-integer"))?
.max(0) as usize,
};
Ok(Value::Record(rewind_record(
&crate::journal::undo(n),
"restored",
)))
}
/// The record shared by `undo` and `session_redo`: one count per outcome, the
/// steps themselves, and `complete`. `applied_key` names the count of steps
/// that were put back (`restored` for undo, `reapplied` for redo).
///
/// Every outcome is counted and listed, including the ones that could not be
/// applied. The counts are kept apart because "3 restored" and "3 restored, 1
/// skipped" call for different decisions.
fn rewind_record(
outcomes: &[crate::journal::Outcome],
applied_key: &str,
) -> std::collections::BTreeMap<String, Value> {
let (mut applied, mut removed, mut skipped, mut failed) = (0, 0, 0, 0);
let mut rows = Vec::with_capacity(outcomes.len());
for o in outcomes {
let mut m = std::collections::BTreeMap::new();
let (action, what, why) = match o {
crate::journal::Outcome::Restored(w) => {
applied += 1;
("restored", w.clone(), String::new())
}
crate::journal::Outcome::Reapplied(w) => {
applied += 1;
("reapplied", w.clone(), String::new())
}
crate::journal::Outcome::Removed(w) => {
removed += 1;
("removed", w.clone(), String::new())
}
crate::journal::Outcome::Skipped { what, why } => {
skipped += 1;
("skipped", what.clone(), why.clone())
}
crate::journal::Outcome::Failed { what, why } => {
failed += 1;
("failed", what.clone(), why.clone())
}
};
m.insert("action".to_string(), Value::Str(action.to_string()));
m.insert("what".to_string(), Value::Str(what));
if !why.is_empty() {
m.insert("why".to_string(), Value::Str(why));
}
rows.push(Value::Record(m));
}
let mut out = std::collections::BTreeMap::new();
out.insert(applied_key.to_string(), Value::Int(applied));
out.insert("removed".to_string(), Value::Int(removed));
out.insert("skipped".to_string(), Value::Int(skipped));
out.insert("failed".to_string(), Value::Int(failed));
out.insert("steps".to_string(), Value::Array(rows));
// Say it in words as well as counts: a caller that reads only the summary
// still learns the world was not fully restored.
out.insert(
"complete".to_string(),
Value::Bool(skipped == 0 && failed == 0),
);
out
}
/// The rows `journal()` and `session_history()` report for done steps.
fn journal_rows(entries: &[crate::journal::Entry]) -> Vec<Value> {
entries
.iter()
.map(|e| {
let mut m = std::collections::BTreeMap::new();
m.insert("seq".to_string(), Value::Int(e.seq as i64));
m.insert("builtin".to_string(), Value::Str(e.builtin.clone()));
m.insert("effect".to_string(), Value::Str(e.effect.clone()));
if !e.path.is_empty() {
m.insert("path".to_string(), Value::Str(e.path.clone()));
}
m.insert("reversible".to_string(), Value::Bool(e.reversible()));
if let crate::journal::Before::Irreversible(why) = &e.before {
m.insert("why".to_string(), Value::Str(why.clone()));
}
Value::Record(m)
})
.collect()
}
/// `journal()` — the recorded steps, and how many of them can be reversed.
/// record_summary(): this session's opt-in evaluation records, summarised; see
/// src/record.rs. Zeros and `enabled: false` unless IRONSHELL_RECORD is set.
fn bi_record_summary(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
no_args_or_input("record_summary", &args, &input)?;
Ok(crate::record::summary())
}
fn bi_journal() -> Result<Value> {
let entries = crate::journal::entries();
let reversible = crate::journal::reversible_count();
let rows = journal_rows(&entries);
let mut out = std::collections::BTreeMap::new();
out.insert("steps".to_string(), Value::Int(entries.len() as i64));
out.insert("reversible".to_string(), Value::Int(reversible as i64));
out.insert(
"irreversible".to_string(),
Value::Int((entries.len() - reversible) as i64),
);
out.insert(
"enabled".to_string(),
Value::Bool(crate::journal::enabled()),
);
out.insert("entries".to_string(), Value::Array(rows));
Ok(Value::Record(out))
}
/// `journal_clear()` — forget the recorded steps. Touches no files; the
/// captured contents are dropped, so anything before this point can no longer
/// be rewound.
fn bi_journal_clear(input: Option<Value>) -> Result<Value> {
refuse_pipeline_input("journal_clear", &input)?;
let n = crate::journal::entries().len();
clear_journal_or_refuse("journal_clear")?;
let mut m = std::collections::BTreeMap::new();
m.insert("forgotten".to_string(), Value::Int(n as i64));
m.insert(
"note".to_string(),
Value::Str("no files changed; these steps can no longer be undone".to_string()),
);
Ok(Value::Record(m))
}
/// Clear the journal, or say what survived: an answer that the steps are gone
/// must not stand while their files, which a later process loads, remain.
fn clear_journal_or_refuse(builtin: &str) -> Result<()> {
crate::journal::try_clear().map_err(|(dir, err)| {
crate::safety::io_fault(
builtin,
&format!(
"the journal in {} could not be removed: {err}",
dir.display()
),
"remove that directory by hand; it may hold the contents of overwritten files, and a later process will load what is left",
)
})
}
/// `handle(id)` — the full value behind a handle, exactly as computed.
///
/// A missing id is a structured error rather than a null, and it names the live
/// handles: a handle from a previous process is gone, and an agent that gets
/// `null` back cannot tell that apart from an empty result.
fn bi_handle(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_pipeline_input("handle", &input)?;
let id = args
.first()
.and_then(|v| v.as_str().ok())
.map(|s| s.to_string())
.ok_or_else(|| crate::safety::arg_err("handle(id): a handle id is required"))?;
match crate::handles::get(&id) {
Some(v) => Ok(v),
None => {
let live: Vec<String> = crate::handles::list().into_iter().map(|h| h.id).collect();
let got = if live.is_empty() {
format!("'{id}' (no handles live — they do not survive the process)")
} else {
format!("'{id}' (live: {})", live.join(", "))
};
Err(crate::safety::bad_arg(
"handle",
"a live handle id, as listed by handles()",
&got,
))
}
}
}
/// `handles()` — every live handle with its shape and size, without the data.
fn bi_handles() -> Result<Value> {
Ok(Value::Array(
crate::handles::list()
.into_iter()
.map(|h| {
let mut m = std::collections::BTreeMap::new();
m.insert("id".to_string(), Value::Str(h.id));
m.insert("shape".to_string(), Value::Str(h.shape));
m.insert("items".to_string(), Value::Int(h.items as i64));
m.insert("bytes".to_string(), Value::Int(h.bytes as i64));
Value::Record(m)
})
.collect(),
))
}
/// `handle_drop(id)` — release a handle, reporting whether it existed.
fn bi_handle_drop(args: Vec<Value>) -> Result<Value> {
let id = args
.first()
.and_then(|v| v.as_str().ok())
.map(|s| s.to_string())
.ok_or_else(|| crate::safety::arg_err("handle_drop(id): a handle id is required"))?;
Ok(Value::Bool(crate::handles::drop_handle(&id)))
}
/// Render a large result as a handle: what it is, how big, a short preview, and
/// the exact call that narrows it down.
///
/// The hint is not decoration — it is how the agent discovers `handle` at all.
/// These builtins are reachable but not in the ontology listing, so the call
/// site teaches itself at the moment it is needed, which beats requiring a
/// discovery round-trip to learn how to avoid a discovery round-trip.
fn render_handle(v: Value, full_bytes: usize) -> anyhow::Result<String> {
let preview = crate::handles::preview_of(&v);
let omitted = crate::handles::omitted(&v);
let h = crate::handles::put(v, full_bytes)?;
let mut out = String::new();
out.push_str(&format!("@handle {}\n", h.id));
out.push_str(&format!("@shape {}\n", h.shape));
out.push_str(&format!("@items {}\n", h.items));
out.push_str(&format!("@bytes {}\n", h.bytes));
out.push_str(&aecon_render(&preview));
if omitted > 0 {
// Stated explicitly: a preview that looks like a result is how silent
// truncation misleads.
out.push_str(&format!("\n@omitted {omitted}"));
}
out.push_str(&format!(
"\n@hint handle(\"{}\") | where(...) | take(5) — full value kept, nothing lost",
h.id
));
Ok(out)
}
/// Recursively redact known secret shapes from a value before it is rendered to
/// an agent. Scalar strings are shape-scrubbed (API-key prefixes, JWTs, PEM
/// blocks, URL credentials, `key=secret` assignments) via `safety::redact_str`;
/// any record/table field whose *name* denotes a secret is replaced with the
/// marker even when its value doesn't match a shape. Structure is preserved —
/// only secret leaves change — and the transform is deterministic.
pub fn redact_value(v: &Value) -> Value {
match v {
Value::Str(s) => {
let (r, _) = crate::safety::redact_str(s);
Value::Str(r)
}
Value::Uri(s) => {
let (r, _) = crate::safety::redact_str(s);
Value::Uri(r)
}
Value::Array(items) => Value::Array(items.iter().map(redact_value).collect()),
Value::Record(m) => Value::Record(redact_field_map(m)),
Value::Table(t) => Value::Table(crate::value::Table {
rows: t.rows.iter().map(redact_field_map).collect(),
schema: t.schema.clone(),
}),
other => other.clone(),
}
}
/// Redact a record/row field map: secret-named fields become the marker;
/// every other field is recursively redacted.
fn redact_field_map(
m: &std::collections::BTreeMap<String, Value>,
) -> std::collections::BTreeMap<String, Value> {
let mut out = std::collections::BTreeMap::new();
for (k, val) in m {
if crate::safety::is_secret_name(k) && redactable_by_name(val) {
out.insert(
k.clone(),
Value::Str(crate::safety::REDACTION_MARKER.to_string()),
);
} else {
out.insert(k.clone(), redact_value(val));
}
}
out
}
/// Whether a value under a secret-*sounding* field name should actually be
/// replaced by the marker.
///
/// `is_secret_name` matches substrings, so `TOKEN` also matches `full_tokens`,
/// `compact_tokens`, `page_tokens` and `tokens_in`. Those hold **counts**, and
/// redacting them made the token-economy surface — the thing this project
/// measures itself by — report `[REDACTED]` to agents instead of numbers.
///
/// Two rules, both narrow:
///
/// * A secret is a string. A number, bool or null under a secret-sounding name
/// cannot be one, and blanking it destroys information for no gain.
/// * An **approval or plan token** (`apv_…`, `apl_…`) is a capability the agent
/// is required to echo back, not a credential to hide. `plan()` returned
/// `token="[REDACTED]"` in its machine-readable field while printing the real
/// token in `hint` — incoherent, and it broke the documented plan/apply flow
/// for any caller reading the structured field.
///
/// Found by driving the shell as an agent.
fn redactable_by_name(v: &Value) -> bool {
match v {
Value::Str(s) | Value::Uri(s) => !crate::safety::is_capability_token(s),
// Containers are walked normally so genuine secrets inside are still
// caught by shape and by their own field names.
Value::Array(_) | Value::Record(_) | Value::Table(_) => false,
_ => false,
}
}
/// Render a result for **deterministic mode** (`--deterministic` / `IRONSHELL_DETERMINISTIC`):
/// canonical JSON — byte-identical for equal values across OS/locale (sorted keys,
/// shortest round-trip floats), for snapshot tests, content-addressable caching,
/// and cross-tool diffs. The whole value is emitted (no budget truncation, which
/// would break reproducibility of the full result). `Null` prints nothing.
pub fn render_canonical(v: &Value) -> Option<String> {
match v {
Value::Null => None,
_ => Some(canonical_render(v)),
}
}
/// Format a `budget_value`/`budget_string_record` envelope as AECON page text plus
/// a single `@page k=v…` metadata line (the page field is already AECON text, so
/// it is emitted raw rather than re-quoted).
fn render_budget_envelope(env: &Value) -> String {
if let Value::Record(m) = env {
let page = match m.get("page") {
Some(Value::Str(s)) => s.clone(),
_ => String::new(),
};
let meta: Vec<String> = m
.iter()
.filter(|(k, _)| k.as_str() != "page")
.map(|(k, val)| format!("{}={}", k, aecon_atom(val)))
.collect();
if meta.is_empty() {
page
} else {
format!("{}\n@page {}", page, meta.join("\t"))
}
} else {
aecon_render(env)
}
}
// ─────────────────────────────────────────────────────────────────────────
// Plan / Apply (docs/AGENTIC_FIRST_DESIGN.md §9): a destructive batch is
// described declaratively, reviewed (typed plan + blast radius + a bound token),
// approved once, then applied ATOMICALLY inside a transaction (all-or-nothing,
// auto-rollback on any failure). Composes approval (safety) + transactions (tx)
// + structured output. Supported ops: write{path,content}, rm{path}, mkdir{path}.
// ─────────────────────────────────────────────────────────────────────────
/// A content-bound token for a plan (`apl_<16 hex of sha256(canonical(ops))>`),
/// so approving one plan cannot authorize a different set of operations.
fn plan_token(ops: &Value) -> Result<String> {
let digest = ic_sha256("plan/apply", canonical_render(ops).as_bytes())?;
let hex: String = digest
.iter()
.take(8)
.map(|b| format!("{:02x}", b))
.collect();
Ok(format!("apl_{}", hex))
}
/// Validate and normalize plan ops into `(op, path, content?)` tuples.
#[allow(clippy::type_complexity)]
fn parse_plan_ops(ops: &Value) -> Result<Vec<(String, String, Option<String>, Option<String>)>> {
let arr = match ops {
Value::Array(a) => a,
other => {
return Err(crate::safety::bad_arg(
"plan/apply",
"ops: Array<Record{op, path, content?}>",
other.type_name(),
))
}
};
let mut out = Vec::with_capacity(arr.len());
for (i, e) in arr.iter().enumerate() {
let m = match e {
Value::Record(m) => m,
_ => {
return Err(crate::safety::arg_err(format!(
"plan: op #{} must be a record {{op, path, content?}}",
i
)))
}
};
let op = match m.get("op") {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(crate::safety::arg_err(format!(
"plan: op #{} missing 'op' string",
i
)))
}
};
let path = match m.get("path") {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(crate::safety::arg_err(format!(
"plan: op #{} missing 'path' string",
i
)))
}
};
let content = match m.get("content") {
Some(Value::Str(s)) => Some(s.clone()),
_ => None,
};
// `copy`/`move` carry a destination path in `dest` (or `to`).
let dest = match m.get("dest").or_else(|| m.get("to")) {
Some(Value::Str(s)) => Some(s.clone()),
_ => None,
};
match op.as_str() {
"write" | "append" if content.is_none() => {
return Err(crate::safety::arg_err(format!(
"plan: {} op #{} requires 'content'",
op, i
)))
}
"copy" | "move" if dest.is_none() => {
return Err(crate::safety::arg_err(format!(
"plan: {} op #{} requires 'dest' (destination path)",
op, i
)))
}
"write" | "append" | "rm" | "mkdir" | "copy" | "move" => {}
other => {
return Err(crate::safety::arg_err(format!(
"plan: op #{} unsupported op '{}' (expected write|append|rm|mkdir|copy|move)",
i, other
)))
}
}
out.push((op, path, content, dest));
}
Ok(out)
}
/// plan(ops) - Produce a typed, reviewable plan for a destructive batch without
/// executing it: a summary (writes/deletes/mkdirs), operation count, and a
/// content-bound approval `token`. Review, `approve(token)`, then `apply(ops)`.
fn bi_plan(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let ops = args
.first()
.cloned()
.ok_or_else(|| crate::safety::bad_arg("plan", "ops: Array<Record{op, path}>", "nothing"))?;
let parsed = parse_plan_ops(&ops)?;
let (mut writes, mut appends, mut deletes, mut mkdirs, mut copies, mut moves) =
(0i64, 0i64, 0i64, 0i64, 0i64, 0i64);
for (op, _, _, _) in &parsed {
match op.as_str() {
"write" => writes += 1,
"append" => appends += 1,
"rm" => deletes += 1,
"mkdir" => mkdirs += 1,
"copy" => copies += 1,
"move" => moves += 1,
_ => {}
}
}
let token = plan_token(&ops)?;
let mut summary = BTreeMap::new();
summary.insert("writes".to_string(), Value::Int(writes));
summary.insert("appends".to_string(), Value::Int(appends));
summary.insert("deletes".to_string(), Value::Int(deletes));
summary.insert("mkdirs".to_string(), Value::Int(mkdirs));
summary.insert("copies".to_string(), Value::Int(copies));
summary.insert("moves".to_string(), Value::Int(moves));
let mut r = BTreeMap::new();
r.insert("operations".to_string(), Value::Int(parsed.len() as i64));
r.insert("summary".to_string(), Value::Record(summary));
r.insert("token".to_string(), Value::Str(token.clone()));
r.insert(
"hint".to_string(),
Value::Str(format!(
"review, then a human approves this token out of band (IRONSHELL_APPROVE={}), then apply(<ops>); an agent cannot approve its own plan",
token
)),
);
r.insert("plan".to_string(), ops);
Ok(Value::Record(r))
}
/// plan_diff(ops) - What a plan would change, as text a reviewer can read.
///
/// `plan()` answers "how many operations, of what kinds". That is enough to
/// decide whether a plan is the right *shape* and not enough to decide whether
/// it is correct: two plans with identical summaries can write entirely
/// different bytes. This renders the actual before/after.
///
/// A separate builtin rather than a field on `plan()` on purpose. A diff is
/// unbounded in a way a summary is not, and this project's convention is
/// progressive disclosure -- an agent that wants the detail asks for it, and
/// one that only wants the shape does not pay for it.
///
/// Output is capped per file, with the elided count reported rather than
/// silently dropped -- the same contract `budget()` uses.
fn bi_plan_diff(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
const MAX_LINES_PER_OP: usize = 40;
let ops = args.first().cloned().ok_or_else(|| {
crate::safety::bad_arg("plan_diff", "ops: Array<Record{op, path}>", "nothing")
})?;
let parsed = parse_plan_ops(&ops)?;
let mut entries = Vec::with_capacity(parsed.len());
let mut total_added = 0i64;
let mut total_removed = 0i64;
for (op, path, content, dest) in &parsed {
let mut rec = BTreeMap::new();
rec.insert("op".to_string(), Value::Str(op.clone()));
rec.insert("path".to_string(), Value::Str(path.clone()));
// What is there now. `None` means the path does not exist or cannot be
// read as text -- reported, never guessed at.
let existing = read_text_capped("plan_diff", path).ok();
let exists = std::path::Path::new(path).exists();
let (before, after): (Vec<String>, Vec<String>) = match op.as_str() {
"write" => (
existing
.as_deref()
.unwrap_or("")
.lines()
.map(str::to_string)
.collect(),
content
.as_deref()
.unwrap_or("")
.lines()
.map(str::to_string)
.collect(),
),
"append" => {
let base: Vec<String> = existing
.as_deref()
.unwrap_or("")
.lines()
.map(str::to_string)
.collect();
let mut end = base.clone();
end.extend(content.as_deref().unwrap_or("").lines().map(str::to_string));
(base, end)
}
"rm" => (
existing
.as_deref()
.unwrap_or("")
.lines()
.map(str::to_string)
.collect(),
Vec::new(),
),
_ => (Vec::new(), Vec::new()),
};
let mut lines: Vec<Value> = Vec::new();
let mut added = 0i64;
let mut removed = 0i64;
match op.as_str() {
"write" | "append" | "rm" => {
// A line-set difference, not a longest-common-subsequence diff:
// it answers "what appears and what disappears" without claiming
// to reconstruct how an editor would have moved things.
for l in &before {
if !after.contains(l) {
removed += 1;
if lines.len() < MAX_LINES_PER_OP {
lines.push(Value::Str(format!("-{l}")));
}
}
}
for l in &after {
if !before.contains(l) {
added += 1;
if lines.len() < MAX_LINES_PER_OP {
lines.push(Value::Str(format!("+{l}")));
}
}
}
if existing.is_none() && exists {
rec.insert(
"note".to_string(),
Value::Str("path exists but is not readable as text".to_string()),
);
}
}
"mkdir" => {
rec.insert(
"note".to_string(),
Value::Str(if exists {
"directory already exists".to_string()
} else {
"creates directory".to_string()
}),
);
}
"copy" | "move" => {
let d = dest.clone().unwrap_or_default();
rec.insert("dest".to_string(), Value::Str(d.clone()));
rec.insert(
"note".to_string(),
Value::Str(if std::path::Path::new(&d).exists() {
format!("{op} over an existing {d}")
} else {
format!("{op} to a new {d}")
}),
);
}
_ => {}
}
rec.insert("exists".to_string(), Value::Bool(exists));
rec.insert("added".to_string(), Value::Int(added));
rec.insert("removed".to_string(), Value::Int(removed));
let shown = lines.len() as i64;
let elided = (added + removed) - shown;
rec.insert("lines".to_string(), Value::Array(lines));
if elided > 0 {
rec.insert("elided".to_string(), Value::Int(elided));
}
total_added += added;
total_removed += removed;
entries.push(Value::Record(rec));
}
let mut out = BTreeMap::new();
out.insert("operations".to_string(), Value::Int(parsed.len() as i64));
out.insert("added".to_string(), Value::Int(total_added));
out.insert("removed".to_string(), Value::Int(total_removed));
out.insert("diff".to_string(), Value::Array(entries));
out.insert("token".to_string(), Value::Str(plan_token(&ops)?));
Ok(Value::Record(out))
}
/// The refusal, if any, that `apply` gives one op: a path or destination outside
/// the workspace jail (when enforced), or a copy/move onto its own source.
/// Paths are already resolved by the caller. Pure: no filesystem writes, audit
/// entries or budget charges, so it can run for every op before any op executes.
fn apply_op_refusal(
op: &str,
path: &str,
dest: Option<&str>,
enforce_jail: bool,
) -> Option<String> {
if enforce_jail && !crate::safety::within_workspace(path) {
return Some(format!("'{}' is outside the workspace", path));
}
// `apply` is self-guarded, so the paths `guard` protects are refused here.
if let Some(why) = crate::safety::protected_write_refusal(path) {
return Some(format!("'{path}' is {why}"));
}
// copy/move write a destination — it is jailed too.
if let Some(d) = dest {
if enforce_jail && !crate::safety::within_workspace(d) {
return Some(format!("destination '{}' is outside the workspace", d));
}
if let Some(why) = crate::safety::protected_write_refusal(d) {
return Some(format!("destination '{d}' is {why}"));
}
if d == path {
return Some(format!("{} {}: destination is the source", op, path));
}
}
None
}
/// The first refusal among a whole plan, checked before the transaction opens.
fn apply_preflight_refusal(
ops: &[(String, String, Option<String>, Option<String>)],
enforce_jail: bool,
) -> Option<String> {
ops.iter()
.find_map(|(op, path, _, dest)| apply_op_refusal(op, path, dest.as_deref(), enforce_jail))
}
/// The `apply` result for a plan refused before anything ran. Nothing was
/// written, so there is no transaction and nothing to roll back.
fn apply_refused_record(err: String, token: &str) -> Value {
let _ = crate::safety::audit(
"apply",
crate::safety::Effect::Destructive,
"failed",
token,
serde_json::json!({ "error": err, "rolled_back": 0, "preflight": true }),
);
let mut r = BTreeMap::new();
r.insert("status".to_string(), Value::Str("failed".to_string()));
r.insert("error".to_string(), Value::Str(err));
r.insert("rolled_back".to_string(), Value::Bool(false));
r.insert("restored".to_string(), Value::Int(0));
r.insert("preflight".to_string(), Value::Bool(true));
Value::Record(r)
}
/// The most ops one `apply` runs, as file_edit and file_patch cap their batches.
const APPLY_MAX_OPS: usize = 10_000;
/// apply(ops) - Execute a plan atomically inside a transaction. In agent mode it
/// requires the plan's token to be approved (else returns `{status:"needs_approval"}`);
/// any failure rolls the whole batch back. Filesystem paths are confined to the
/// workspace jail when enforced. Every op is checked before the first one runs.
fn bi_apply(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let ops = args.first().cloned().ok_or_else(|| {
crate::safety::bad_arg("apply", "ops: Array<Record{op, path}>", "nothing")
})?;
// Each op may copy a file into the transaction journal, so a plan is held
// to the batch limit file_edit and file_patch use, before it is parsed.
if let Value::Array(a) = &ops {
if a.len() > APPLY_MAX_OPS {
return Err(crate::safety::budget_exceeded(
"apply",
format!(
"the plan has {} ops; at most {APPLY_MAX_OPS} run in one apply",
a.len()
),
"split the plan into smaller ones",
));
}
}
let parsed = parse_plan_ops(&ops)?;
let token = plan_token(&ops)?;
let agent = matches!(crate::safety::current_mode(), crate::safety::Mode::Agent);
if agent && !crate::safety::is_approved(&token) {
let mut r = BTreeMap::new();
r.insert(
"status".to_string(),
Value::Str("needs_approval".to_string()),
);
r.insert("token".to_string(), Value::Str(token.clone()));
r.insert("operations".to_string(), Value::Int(parsed.len() as i64));
r.insert(
"hint".to_string(),
Value::Str(format!(
"a human approves this token out of band (IRONSHELL_APPROVE={}), then re-run apply",
token
)),
);
return Ok(Value::Record(r));
}
if agent {
// An approval is spent by the apply it authorised. Without this, one
// human approval of a plan token stayed in the in-process grant set
// for the life of the process, so the same token could re-run the
// same batch later, against whatever the paths now hold. Revoking
// before the batch runs is fail-closed: if it fails, the agent needs a
// fresh out-of-band approval. An `IRONSHELL_APPROVE` entry is operator
// configuration and is not touched here.
crate::safety::revoke_approval(&token);
}
let enforce_jail = agent || std::env::var("IRONSHELL_WORKSPACE").is_ok();
// Resolve relative paths once, the way the jail judges them (see
// resolve_path_str), so the checks and the operations use the same path.
let resolved: Vec<(String, String, Option<String>, Option<String>)> = parsed
.iter()
.map(|(op, path, content, dest)| {
(
op.clone(),
crate::safety::resolve_path_str(path),
content.clone(),
dest.as_deref().map(crate::safety::resolve_path_str),
)
})
.collect();
// Preflight: every op's path checks run before the transaction opens and
// before any op executes, so a plan refused at op N never touches the disk.
if let Some(err) = apply_preflight_refusal(&resolved, enforce_jail) {
return Ok(apply_refused_record(err, &token));
}
crate::tx::begin()?;
let mut applied = 0i64;
let mut failure: Option<String> = None;
for (op, path, content, dest) in &resolved {
// Already checked by the preflight; repeated here so no write can
// ever run in front of an unchecked path.
if let Some(err) = apply_op_refusal(op, path, dest.as_deref(), enforce_jail) {
failure = Some(err);
break;
}
crate::tx::snapshot(path);
if let Some(d) = dest {
crate::tx::snapshot(d);
}
let res: std::io::Result<()> = match op.as_str() {
"write" => {
write_file_atomically("apply", path, content.as_deref().unwrap_or("").as_bytes())
.map_err(|e| std::io::Error::other(e.to_string()))
}
"append" => {
use std::io::Write;
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.and_then(|mut f| f.write_all(content.as_deref().unwrap_or("").as_bytes()))
}
"rm" => std::fs::remove_file(path),
"mkdir" => std::fs::create_dir_all(path),
"copy" => std::fs::copy(path, dest.as_deref().unwrap_or("")).map(|_| ()),
"move" => std::fs::rename(path, dest.as_deref().unwrap_or("")),
_ => Ok(()),
};
if let Err(e) = res {
failure = Some(format!("{} {}: {}", op, path, e));
break;
}
applied += 1;
}
let mut r = BTreeMap::new();
if let Some(err) = failure {
// Old code reported rolled_back: true even when the rollback itself
// failed (unwrap_or(0)). Report the rollback error instead.
let (rolled_back, restored, rollback_error) = match crate::tx::rollback() {
Ok(n) => (true, n, None),
Err(e) => (false, 0, Some(e.to_string())),
};
let _ = crate::safety::audit(
"apply",
crate::safety::Effect::Destructive,
"failed",
&token,
serde_json::json!({
"error": err,
"rolled_back": restored,
"rollback_error": rollback_error,
}),
);
r.insert("status".to_string(), Value::Str("failed".to_string()));
r.insert("error".to_string(), Value::Str(err));
r.insert("rolled_back".to_string(), Value::Bool(rolled_back));
r.insert("restored".to_string(), Value::Int(restored as i64));
if let Some(rb) = rollback_error {
r.insert("rollback_error".to_string(), Value::Str(rb));
}
} else if let Err(e) = crate::tx::commit() {
// Old code ignored a failed commit (let _ =) and reported "applied".
let err = format!("commit: {}", e);
let _ = crate::safety::audit(
"apply",
crate::safety::Effect::Destructive,
"failed",
&token,
serde_json::json!({ "error": err, "rolled_back": 0 }),
);
r.insert("status".to_string(), Value::Str("failed".to_string()));
r.insert("error".to_string(), Value::Str(err));
r.insert("rolled_back".to_string(), Value::Bool(false));
r.insert("restored".to_string(), Value::Int(0));
} else {
// The operations and the commit have already happened, so a failed
// required audit write cannot undo them. It must still not report
// "applied" as if the record were kept.
if let Err(e) = crate::safety::audit(
"apply",
crate::safety::Effect::Destructive,
"applied",
&token,
serde_json::json!({ "operations": applied }),
) {
return Err(crate::safety::io_fault(
"apply",
&format!(
"the {applied} operations WERE applied and committed, but the required audit record could not be written: {e}"
),
"the changes are in place; do not re-run this plan. Fix the audit log path (IRONSHELL_AUDIT_REQUIRED is set) and check the audit log",
));
}
r.insert("status".to_string(), Value::Str("applied".to_string()));
r.insert("applied".to_string(), Value::Int(applied));
}
Ok(Value::Record(r))
}
/// tx_begin() - Start a filesystem transaction. Subsequent file_write/rm calls
/// record their pre-modification state so the batch can be rolled back.
fn bi_tx_begin(_args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("tx_begin", &input)?;
let id = crate::tx::begin()?;
let mut r = BTreeMap::new();
r.insert("transaction".to_string(), Value::Str(id));
r.insert("active".to_string(), Value::Bool(true));
// Nesting depth after this begin (1 = outermost; a nested begin pushes a child).
r.insert("depth".to_string(), Value::Int(crate::tx::depth() as i64));
Ok(Value::Record(r))
}
/// tx_commit() - Commit the active transaction (keep changes, discard journal).
fn bi_tx_commit(_args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("tx_commit", &input)?;
let n = crate::tx::commit()?;
let mut r = BTreeMap::new();
r.insert("committed".to_string(), Value::Bool(true));
r.insert("operations".to_string(), Value::Int(n as i64));
Ok(Value::Record(r))
}
/// tx_rollback() - Roll back the active transaction, restoring every recorded
/// path to its pre-transaction state.
fn bi_tx_rollback(_args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("tx_rollback", &input)?;
let n = crate::tx::rollback()?;
let mut r = BTreeMap::new();
r.insert("rolled_back".to_string(), Value::Bool(true));
r.insert("restored".to_string(), Value::Int(n as i64));
Ok(Value::Record(r))
}
/// tx_status() - Report whether a transaction is active and its operation count.
fn bi_tx_status(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut r = BTreeMap::new();
match crate::tx::status() {
Some((id, n)) => {
r.insert("active".to_string(), Value::Bool(true));
r.insert("transaction".to_string(), Value::Str(id));
r.insert("operations".to_string(), Value::Int(n as i64));
r.insert("depth".to_string(), Value::Int(crate::tx::depth() as i64));
}
None => {
r.insert("active".to_string(), Value::Bool(false));
r.insert("depth".to_string(), Value::Int(0));
}
}
Ok(Value::Record(r))
}
/// tx_savepoint(name) - Mark a named savepoint inside the active transaction; a
/// later `tx_rollback_to(name)` reverts only the operations recorded after it.
fn bi_tx_savepoint(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("tx_savepoint", &input)?;
let name = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(crate::safety::bad_arg(
"tx_savepoint",
"name: String",
"nothing",
))
}
};
crate::tx::savepoint(&name)?;
let mut r = BTreeMap::new();
r.insert("savepoint".to_string(), Value::Str(name));
Ok(Value::Record(r))
}
/// tx_rollback_to(name) - Roll back to a named savepoint, reverting only the
/// operations recorded after it; the transaction stays open.
fn bi_tx_rollback_to(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("tx_rollback_to", &input)?;
let name = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(crate::safety::bad_arg(
"tx_rollback_to",
"name: String",
"nothing",
))
}
};
let n = crate::tx::rollback_to(&name)?;
let mut r = BTreeMap::new();
r.insert("rolled_back_to".to_string(), Value::Str(name));
r.insert("restored".to_string(), Value::Int(n as i64));
Ok(Value::Record(r))
}
/// safety_status() - Report the current safety operating envelope so an agent
/// knows what it may do before acting: execution mode, acting principal,
/// workspace jail root, audit log, whether a transaction is active, and the
/// resolved policy decision (allow/deny/approve) for every effect class.
fn bi_safety_status(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
use crate::safety::{Decision, Effect, Mode};
let mode = crate::safety::current_mode();
let mode_str = match mode {
Mode::Agent => "agent",
Mode::Human => "human",
};
let effects = [
Effect::Pure,
Effect::ReadLocal,
Effect::WriteLocal,
Effect::Network,
Effect::Process,
Effect::Destructive,
Effect::Exec,
Effect::Privileged,
];
let mut policy = BTreeMap::new();
for e in effects {
let d = match crate::safety::effective_decision(e, mode) {
Decision::Allow => "allow",
Decision::Deny => "deny",
Decision::Approve => "approve",
};
policy.insert(e.as_str().to_string(), Value::Str(d.to_string()));
}
let mut r = BTreeMap::new();
r.insert("mode".to_string(), Value::Str(mode_str.to_string()));
r.insert(
"principal".to_string(),
crate::safety::current_principal()
.map(Value::Str)
.unwrap_or(Value::Null),
);
r.insert(
"workspace".to_string(),
Value::Str(
crate::safety::workspace_root()
.to_string_lossy()
.to_string(),
),
);
r.insert(
"audit_log".to_string(),
crate::safety::audit_path()
.map(|p| Value::Str(p.to_string_lossy().to_string()))
.unwrap_or(Value::Null),
);
r.insert(
"transaction_active".to_string(),
Value::Bool(crate::tx::is_active()),
);
r.insert("policy".to_string(), Value::Record(policy));
r.insert(
"governor".to_string(),
Value::from_json(&crate::safety::governor_snapshot()),
);
Ok(Value::Record(r))
}
/// governor_status() - Report the resource-governor envelope (§7.6): current
/// counts (ops/files/procs), the configured limits (null = unlimited), elapsed
/// wall-clock ms, and whether governors are active (agent mode). Lets an agent
/// watch its blast-radius budget burn down before it hits `E_BUDGET_EXCEEDED`.
fn bi_governor_status(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
Ok(Value::from_json(&crate::safety::governor_snapshot()))
}
/// governor_reset() - Reset the resource-governor counters and wall-clock start,
/// beginning a fresh envelope (e.g. after deliberately raising a limit or at a
/// session boundary).
///
/// In agent mode it is refused in the body, as `approve` is. Left callable, an
/// agent could clear its own IRONSHELL_MAX_OPS/FILES/PROCS/NET counters and its
/// wall-clock envelope, which is the budget the governor exists to enforce. The
/// limits are environment variables read at startup, so the out-of-band route is
/// to raise them before launch, not to reset them mid-run. Human mode is untouched.
fn bi_governor_reset(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
if crate::safety::current_mode() == crate::safety::Mode::Agent {
return Err(anyhow::Error::new(crate::safety::SafetyError {
code: crate::safety::ErrorCode::PolicyDeny,
message: "governor_reset: an agent may not reset its own budget".to_string(),
builtin: "governor_reset".to_string(),
hint: "the limits are set by whoever launched the agent: raise \
IRONSHELL_MAX_OPS, IRONSHELL_MAX_FILES, IRONSHELL_MAX_PROCS, \
IRONSHELL_MAX_NET or IRONSHELL_TIMEOUT_MS before launch"
.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
}));
}
crate::safety::governor_reset();
Ok(Value::Bool(true))
}
// ─────────────────────────────────────────────────────────────────────────
// Stateful sessions (docs/AGENTIC_FIRST_DESIGN.md §6.3): a persistent Env keyed
// by session id, so an agent builds up state across calls (`let x = …` then use
// `x` later) WITHOUT re-sending prior context each turn — a token-efficiency and
// capability win over the single-shot eval path.
// ─────────────────────────────────────────────────────────────────────────
static SESS_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// A stateful session: a persistent environment plus running token accounting.
struct Session {
env: crate::env::Env,
tokens_in: u64,
tokens_out: u64,
evals: u64,
}
impl Session {
fn new() -> Self {
// Register the module namespaces (file, sys, str, http, …) so a session
// can run module-qualified calls like `file.read(…)`, not just builtins.
let env = crate::modules::env_with_modules();
Self {
env,
tokens_in: 0,
tokens_out: 0,
evals: 0,
}
}
}
lazy_static::lazy_static! {
static ref SESSIONS: std::sync::Mutex<HashMap<String, Session>> =
std::sync::Mutex::new(HashMap::new());
}
/// Most sessions one process holds open. A session carries a whole Env.
const SESSION_MAX: usize = 64;
/// Longest code one sess_eval call may send.
const SESSION_CODE_MAX: usize = 64 * 1024;
/// sess_open() - Create a stateful session and return its id. Subsequent
/// `sess_eval(id, code)` calls share one persistent environment.
fn bi_sess_open(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped_subject("sess_open", &input)?;
if !args.is_empty() {
return Err(crate::safety::arg_err("sess_open takes no arguments"));
}
let id = format!(
"sess_{}_{}",
std::process::id(),
SESS_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
);
let mut store = SESSIONS
.lock()
.map_err(|_| crate::safety::lock_poisoned("sess_open", "the session store"))?;
if store.len() >= SESSION_MAX {
return Err(crate::safety::budget_exceeded(
"sess_open",
format!("sess_open: {SESSION_MAX} sessions are already open"),
"close one with sess_close, then open another",
));
}
store.insert(id.clone(), Session::new());
let mut r = BTreeMap::new();
r.insert("session".to_string(), Value::Str(id));
r.insert("active".to_string(), Value::Bool(true));
Ok(Value::Record(r))
}
/// sess_eval(id, code) - Evaluate code in a session's persistent environment;
/// `let` bindings and mutations persist across calls. Returns the result value.
/// Each builtin the code calls passes the same dispatch guard as at the REPL, so
/// agent-mode policy applies inside a session too.
fn bi_sess_eval(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped_subject("sess_eval", &input)?;
let id = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"sess_eval",
"session_id: String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let code = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"sess_eval",
"code: String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
if code.len() > SESSION_CODE_MAX {
return Err(crate::safety::budget_exceeded(
"sess_eval",
format!(
"sess_eval: the code is {} bytes; one call evaluates at most {SESSION_CODE_MAX}",
code.len()
),
"split the code across calls; bindings persist between them",
));
}
// Take the env out (not holding the lock across eval — avoids deadlock if the
// evaluated code touches the session store) and put it back afterward so
// bindings made before any error still persist.
let mut session = {
let mut store = SESSIONS
.lock()
.map_err(|_| crate::safety::lock_poisoned("sess_eval", "the session store"))?;
match store.remove(&id) {
Some(s) => s,
None => return Err(crate::safety::not_found("sess_eval", "session", &id)),
}
};
let result = (|| -> Result<Value> {
let stmts = crate::parser::parse_program(&code)?;
crate::eval::eval_program(&stmts, &mut session.env)
})();
// Accumulate running token usage for this session.
session.evals += 1;
session.tokens_in += est_token_count(&code) as u64;
let out_str = match &result {
Ok(v) => v.to_display_string(),
Err(e) => e.to_string(),
};
session.tokens_out += est_token_count(&out_str) as u64;
SESSIONS
.lock()
.map_err(|_| crate::safety::lock_poisoned("sess_eval", "the session store"))?
.insert(id, session);
result
}
/// sess_usage(id) - Running token accounting for a session: estimated
/// `tokens_in`/`tokens_out`/`tokens_total` and the number of `evals`.
fn bi_sess_usage(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped_subject("sess_usage", &input)?;
let id = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"sess_usage",
"session_id: String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let store = SESSIONS
.lock()
.map_err(|_| crate::safety::lock_poisoned("sess_usage", "the session store"))?;
match store.get(&id) {
Some(s) => {
let mut r = BTreeMap::new();
r.insert("tokens_in".to_string(), Value::Int(s.tokens_in as i64));
r.insert("tokens_out".to_string(), Value::Int(s.tokens_out as i64));
r.insert(
"tokens_total".to_string(),
Value::Int((s.tokens_in + s.tokens_out) as i64),
);
r.insert("evals".to_string(), Value::Int(s.evals as i64));
Ok(Value::Record(r))
}
None => Err(crate::safety::not_found("sess_usage", "session", &id)),
}
}
/// sess_close(id) - Discard a session and its environment.
fn bi_sess_close(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let id = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"sess_close",
"session_id: String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let existed = SESSIONS
.lock()
.map_err(|_| crate::safety::lock_poisoned("sess_close", "the session store"))?
.remove(&id)
.is_some();
let mut r = BTreeMap::new();
r.insert("closed".to_string(), Value::Bool(existed));
Ok(Value::Record(r))
}
/// ontology_manifest() - Compact root of the ontology for progressive
/// disclosure: categories with builtin counts, effect classes, and samples,
/// plus the effect legend. Load this cheaply, then `ontology_describe` the slice
/// you need instead of carrying the full ontology in context.
fn bi_ontology_manifest(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
Ok(Value::from_json(&crate::agent_api::ontology_manifest_json()))
}
/// ontology_describe(name) - Expand one ontology slice: a category name → its
/// builtins (name, signature, effect), or a builtin name → its full definition
/// (effect, params, examples).
fn bi_ontology_describe(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let q = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"ontology_describe",
"name: String (category or builtin)",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
Ok(Value::from_json(&crate::agent_api::ontology_describe_json(
&q,
)))
}
/// Pull a string field out of a caught-error record, accepting either the
/// wrapper form `{error: {...}}` that try/catch binds or the bare inner record.
fn diag_field<'a>(err: &'a BTreeMap<String, Value>, key: &str) -> Option<&'a Value> {
if let Some(Value::Record(inner)) = err.get("error") {
if let Some(v) = inner.get(key) {
return Some(v);
}
}
err.get(key)
}
/// diagnose(error) - The **minimal** repair context for a failed call.
///
/// Progressive disclosure applied to failure (§5.4): given the structured error
/// a `catch` block bound, return only what is needed to fix *this* call — the
/// code, whether retrying can help, the offending builtin's signature and effect
/// class, its required parameters, and one example — instead of making the agent
/// pay for a full `ontology_describe` dump or, worse, the whole ontology.
///
/// Deliberately returns *less* than `ontology_describe`: no prose description,
/// no category listing, at most one example. Unknown/absent builtin names yield
/// the code and hint alone rather than an invented suggestion.
fn bi_diagnose(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let subject = args.first().cloned().or(input);
let err = match subject {
Some(Value::Record(r)) => r,
// A bare string is what a non-safety failure used to look like. Report
// it honestly as uncoded rather than guessing a code from its prose.
Some(Value::Str(s)) => {
let mut out = BTreeMap::new();
out.insert("code".to_string(), Value::Str("E_UNKNOWN".to_string()));
out.insert("retryable".to_string(), Value::Bool(false));
out.insert("message".to_string(), Value::Str(s));
out.insert(
"hint".to_string(),
Value::Str(
"this failure carries no structured code; it cannot be \
repaired programmatically"
.to_string(),
),
);
return Ok(Value::Record(out));
}
other => {
return Err(crate::safety::bad_arg(
"diagnose",
"error: Record (the value bound by catch)",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let mut out = BTreeMap::new();
let code = match diag_field(&err, "code") {
Some(Value::Str(c)) => c.clone(),
_ => "E_UNKNOWN".to_string(),
};
out.insert("code".to_string(), Value::Str(code.clone()));
if let Some(v) = diag_field(&err, "retryable") {
out.insert("retryable".to_string(), v.clone());
}
// `bad_arg`'s hint is "pass an argument matching: {expected}", and `expected`
// is emitted below as its own field. Carrying both is the same duplication
// this builtin already refuses for `return_type` and `parameters` -- and it
// is not free: an expected clause that names a full signature costs as much
// again. Emitted only when it says something `expected` does not.
if let Some(Value::Str(hint)) = diag_field(&err, "hint") {
let restates_expected = matches!(
diag_field(&err, "expected"),
Some(Value::Str(e)) if !e.is_empty() && hint.ends_with(e.as_str())
// ...and only when `expected` actually survives into the output, which
// it does only alongside `got`. Otherwise dropping the hint would drop
// the information rather than deduplicate it.
) && diag_field(&err, "got").is_some();
if !restates_expected {
out.insert("hint".to_string(), Value::Str(hint.clone()));
}
}
if let Some(v) = diag_field(&err, "did_you_mean") {
out.insert("did_you_mean".to_string(), v.clone());
}
if let Some(v) = diag_field(&err, "approval") {
out.insert("approval".to_string(), v.clone());
}
// The typed argument pair, when the failure carries one: an agent repairing a
// `E_BAD_ARG` should not have to parse English to learn what shape was wanted.
if let (Some(e), Some(g)) = (diag_field(&err, "expected"), diag_field(&err, "got")) {
out.insert("expected".to_string(), e.clone());
out.insert("got".to_string(), g.clone());
}
// The expensive part — the builtin's own contract — is fetched only when the
// error actually names a builtin that exists.
let name = match diag_field(&err, "builtin") {
Some(Value::Str(b)) if !b.is_empty() => b.clone(),
_ => return Ok(Value::Record(out)),
};
let def = crate::agent_api::ontology_describe_json(&name);
if def.get("builtin").and_then(|v| v.as_str()).is_none() {
// Named a builtin the ontology does not document: say so, don't invent.
return Ok(Value::Record(out));
}
for key in ["builtin", "signature", "effect"] {
if let Some(v) = def.get(key) {
out.insert(key.to_string(), Value::from_json(v));
}
}
// A declared builtin refuses a malformed call by naming its signature, and
// the signature field above is generated from that same declaration. When
// they are the same string, one of them is free to go; `got` still says
// what was wrong with the call.
if let (Some(e), Some(sg)) = (out.get("expected"), out.get("signature")) {
if e == sg {
out.remove("expected");
}
}
// Whether re-running the *operation* is safe, which is a different question from
// whether the *error* was transient (`retryable`). After a timeout an agent knows
// the error may be worth retrying; only this says whether retrying can duplicate
// the effect. Emitted only when it adds something `retryable` does not already
// imply — a retryable error on an idempotent call is the uninteresting case.
let idem = crate::safety::idempotent(&name);
let err_retryable = matches!(out.get("retryable"), Some(Value::Bool(true)));
if !idem || err_retryable {
out.insert("idempotent".to_string(), Value::Bool(idem));
}
// A signature already spells out the parameters and the return type. Emitting
// them again is pure duplication in the exact place we are trying to be cheap,
// so the expanded forms appear only when there is no signature to read.
let has_signature = matches!(out.get("signature"), Some(Value::Str(s)) if !s.is_empty());
if !has_signature {
if let Some(v) = def.get("return_type") {
out.insert("return_type".to_string(), Value::from_json(v));
}
}
if let Some(params) = def
.get("parameters")
.and_then(|v| v.as_array())
.filter(|_| !has_signature)
{
// Compact: name:type, required-ness only — descriptions are prose the
// repair does not need.
let compact: Vec<Value> = params
.iter()
.map(|p| {
let nm = p.get("name").and_then(|v| v.as_str()).unwrap_or("_");
let ty = p.get("type").and_then(|v| v.as_str()).unwrap_or("Any");
let req = p.get("required").and_then(|v| v.as_bool()).unwrap_or(false);
Value::Str(format!("{}:{}{}", nm, ty, if req { "" } else { "?" }))
})
.collect();
out.insert("parameters".to_string(), Value::Array(compact));
}
if let Some(ex) = def
.get("examples")
.and_then(|v| v.as_array())
.and_then(|a| a.first())
.and_then(|e| e.get("code"))
.and_then(|c| c.as_str())
{
out.insert("example".to_string(), Value::Str(ex.to_string()));
}
Ok(Value::Record(out))
}
/// try_repair(code) - Run `code` so that a *failed* attempt leaves no trace.
///
/// The honest description: this does not invent a fix. It makes retrying safe.
/// A self-healing loop that re-runs a mutating batch is only sound if a failed
/// attempt is undone first — otherwise attempt N+1 starts from the debris of
/// attempt N and the failures compound. `try_repair` brackets the evaluation in
/// a named savepoint (§9) and, on failure, rolls back to it, so every attempt
/// starts from identical state.
///
/// Returns `{ok, value}` on success, or `{ok: false, restored, error, retryable}`
/// on failure — `error` being the same structured record `catch` would bind, so
/// it feeds straight into `diagnose()`. Rolls back only the ops *this* call
/// recorded: an enclosing transaction stays open and keeps its earlier work.
fn bi_try_repair(args: Vec<Value>, input: Option<Value>, env: &mut Env) -> Result<Value> {
let code = match args.first().cloned().or(input) {
Some(Value::Str(s)) => s,
other => {
return Err(crate::safety::bad_arg(
"try_repair",
"code: String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
// Reuse an enclosing transaction when there is one (its earlier work must
// survive); otherwise open one for the duration of the attempt.
let owned = !crate::tx::is_active();
if owned {
crate::tx::begin().map_err(|e| {
// A journal directory that could not be made keeps its own code.
if e.downcast_ref::<crate::safety::SafetyError>().is_some() {
return e;
}
crate::safety::bad_state(
"try_repair",
&e.to_string(),
"finish or roll back the open transaction",
)
})?;
}
let mark = format!("__repair_{}", REPAIR_SEQ.fetch_add(1, Ordering::Relaxed));
crate::tx::savepoint(&mark).map_err(|e| {
crate::safety::bad_state(
"try_repair",
&e.to_string(),
"finish or roll back the open transaction",
)
})?;
let result = (|| -> Result<Value> {
let stmts = crate::parser::parse_program(&code)?;
crate::eval::eval_program(&stmts, env)
})();
let mut out = BTreeMap::new();
match result {
Ok(v) => {
if owned {
crate::tx::commit().map_err(|e| {
crate::safety::bad_state(
"try_repair",
&e.to_string(),
"finish or roll back the open transaction",
)
})?;
}
out.insert("ok".to_string(), Value::Bool(true));
out.insert("value".to_string(), v);
}
Err(e) => {
// Undo this attempt's mutations before reporting, so the caller's
// next attempt starts from the same state this one did.
// A restore that failed means the next attempt would not start
// from this one's state; it is reported, and the attempt is then
// not retryable.
let (restored, mut restore_error) = match crate::tx::rollback_to(&mark) {
Ok(n) => (n, None),
Err(e) => (0, Some(e.to_string())),
};
if owned {
if let Err(e) = crate::tx::rollback() {
restore_error.get_or_insert(e.to_string());
}
}
// Every failure reaching here is structured (the builtin boundary
// guarantees it), but a parse error raised before dispatch is not —
// so pass it through the same net.
let coded = crate::safety::ensure_structured("try_repair", e);
let json = coded
.downcast_ref::<crate::safety::SafetyError>()
.map(|se| se.to_json())
.unwrap_or(serde_json::Value::Null);
// `to_json` wraps in `{error: {...}}`; unwrap it so the caller reads
// `result.error.code`, not `result.error.error.code`.
let structured = Value::from_json(json.get("error").unwrap_or(&json));
let retryable = match &structured {
Value::Record(r) => match r.get("retryable") {
Some(Value::Bool(b)) => *b,
_ => false,
},
_ => false,
};
out.insert("ok".to_string(), Value::Bool(false));
out.insert("restored".to_string(), Value::Int(restored as i64));
out.insert(
"retryable".to_string(),
Value::Bool(retryable && restore_error.is_none()),
);
if let Some(why) = restore_error {
out.insert("restore_error".to_string(), Value::Str(why));
}
out.insert("error".to_string(), structured);
}
}
Ok(Value::Record(out))
}
/// Monotonic counter making each `try_repair` savepoint name unique, so nested
/// or sequential attempts cannot roll back to each other's mark.
static REPAIR_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
lazy_static::lazy_static! {
/// The process-global RBAC manager backing the in-shell `rbac_*` builtins
/// and the safety layer's principal authorization.
static ref GLOBAL_RBAC: std::sync::Arc<crate::auth::RbacManager> =
std::sync::Arc::new(crate::auth::RbacManager::new());
}
/// Return the global RBAC manager, (re-)installing it into the safety layer so
/// guarded builtins consult it. Idempotent; called by each `rbac_*` builtin.
fn global_rbac() -> std::sync::Arc<crate::auth::RbacManager> {
let m = GLOBAL_RBAC.clone();
crate::safety::set_rbac_manager(m.clone());
m
}
/// rbac_principal(user_id?) - Set or get the current acting principal for safety
/// authorization. With an argument, sets the principal (creating the user if
/// needed) and activates RBAC; with none, returns the current principal or null.
fn bi_rbac_principal(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mgr = global_rbac();
match args.first() {
Some(Value::Str(uid)) => {
if uid.is_empty() || uid.len() > 256 || uid.chars().any(char::is_control) {
return Err(crate::safety::bad_arg(
"rbac_principal",
"a user id of 1 to 256 characters with no control characters",
&format!("{uid:?}"),
));
}
// Only the *setting* form is privileged; reading back the current
// principal (the argless form below) is not, and an agent that
// cannot ask what it is acting as will guess.
crate::safety::guard(crate::safety::GuardCtx {
builtin: "rbac_principal",
effect: crate::safety::Effect::Privileged,
what: "act as a different principal",
targets: vec![uid.clone()],
blast_radius: serde_json::json!({ "principal": true }),
reversible: true,
fs_paths: false,
})?;
// Naming a user who has a password does not prove anything: that
// account is entered through `rbac_login`, or it is not entered.
// Without this check `rbac_principal("admin")` became admin.
// The name may be the account's id or its username; either one
// resolves to the same password-protected account.
if let Some(account) = resolve_rbac_user(&mgr, uid) {
if GLOBAL_AUTH.has_password(&account.id) {
return Err(crate::safety::policy_deny(
"rbac_principal",
format!(
"rbac_principal: '{uid}' has a password and cannot be assumed by name"
),
"authenticate as that user with rbac_login(username, password)",
));
}
}
if mgr.get_user(uid).is_none() {
let mut u = crate::auth::User::new(uid.clone());
u.id = uid.clone(); // use the id as the principal handle
mgr.add_user(u)?;
}
crate::safety::set_principal(Some(uid.clone()));
Ok(Value::Str(uid.clone()))
}
None | Some(Value::Null) => Ok(crate::safety::current_principal()
.map(Value::Str)
.unwrap_or(Value::Null)),
_ => Err(crate::safety::arg_err(
"rbac_principal: expected a user id string or no argument",
)),
}
}
/// rbac_grant(user_id, permission) - Grant a direct permission (e.g.
/// "effect:destructive", "effect:*", "builtin:rm", "*:*") to a user so that,
/// when acting as that principal, guarded ops of that class bypass approval.
fn bi_rbac_grant(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let uid = rbac_user_id("rbac_grant", args.first())?;
let perm = match args.get(1) {
Some(Value::Str(s)) => rbac_permission_arg("rbac_grant", s)?,
other => {
return Err(crate::safety::bad_arg(
"rbac_grant",
"a permission string such as \"effect:destructive\"",
other.map_or("nothing", Value::type_name),
))
}
};
let mgr = global_rbac();
// A registered account is granted by its id or its username, to the same
// record; a name that is neither is created, as before.
let mut user = resolve_rbac_user(&mgr, &uid).unwrap_or_else(|| {
let mut u = crate::auth::User::new(uid.clone());
u.id = uid.clone();
u
});
let user_id = user.id.clone();
// A grant that is already held changed nothing, and says so rather than
// reporting a fresh grant each time it is repeated.
let changed = user.permissions.insert(perm.clone());
if changed {
mgr.add_user(user)?;
mgr.invalidate_user_cache(&user_id);
}
let mut rec = BTreeMap::new();
rec.insert("user".to_string(), Value::Str(user_id));
rec.insert("granted".to_string(), Value::Str(perm));
rec.insert("changed".to_string(), Value::Bool(changed));
Ok(Value::Record(rec))
}
/// The account a name refers to: by id first, then by username, since a
/// registered account's id and its username differ.
fn resolve_rbac_user(mgr: &crate::auth::RbacManager, name: &str) -> Option<crate::auth::User> {
mgr.get_user(name)
.or_else(|| mgr.get_user_by_username(name))
}
/// A user id argument for the `rbac_*` family: a non-empty string of at most
/// 256 bytes with no control characters, since it is written into the audit
/// trail and echoed back.
fn rbac_user_id(builtin: &str, v: Option<&Value>) -> Result<String> {
match v {
Some(Value::Str(s))
if !s.is_empty() && s.len() <= 256 && !s.chars().any(char::is_control) =>
{
Ok(s.clone())
}
other => Err(crate::safety::bad_arg(
builtin,
"a user id string of 1 to 256 characters with no control characters",
other.map_or("nothing", Value::type_name),
)),
}
}
/// A permission argument: `resource:capability`, both halves non-empty, the
/// whole of at most 128 characters from letters, digits and `_ - . * /`.
/// Anything else is refused, so a typo cannot be stored as a grant that
/// matches nothing and looks like one that does.
fn rbac_permission_arg(builtin: &str, s: &str) -> Result<String> {
let well_formed = s.len() <= 128
&& s.split_once(':').is_some_and(|(a, b)| {
!a.is_empty()
&& !b.is_empty()
&& !b.contains(':')
&& s.chars().all(|c| {
c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '*' | '/' | ':')
})
});
if well_formed {
Ok(s.to_string())
} else {
Err(crate::safety::bad_arg(
builtin,
"a permission of the form resource:capability, e.g. \"effect:destructive\"",
&format!("{s:?}"),
))
}
}
/// rbac_can(permission) - Whether the current principal holds `permission`
/// (resolving roles + wildcards). Returns false if no principal is set.
fn bi_rbac_can(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let perm = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(crate::safety::arg_err(
"rbac_can: expected a permission string",
))
}
};
let mgr = global_rbac();
let allowed = match crate::safety::current_principal() {
Some(p) => mgr.check_permission(&p, &perm),
None => false,
};
Ok(Value::Bool(allowed))
}
// ------------------------------------------------------------------------
// Interactive login (§7.1)
//
// `auth::AuthManager` -- registration, password verification, sessions, tokens,
// API keys, its own audit trail -- existed in full and had no caller anywhere
// in the crate. The only way to become a principal was `rbac_principal(id)`,
// which asserts an identity rather than proving one: any caller could name any
// user and be treated as them.
//
// These four builtins are that missing door. `rbac_login` proves the identity
// against a stored Argon2id hash before it touches `set_principal`, so the
// principal the guard trusts is one somebody authenticated as.
//
// They are `Privileged`, and therefore denied in agent mode: logging in is how
// authority is acquired, and an agent that could acquire authority on its own
// has none. An agent receives its principal from `IRONSHELL_PRINCIPAL` or the RBAC
// config file at startup, chosen by whoever launched it.
// ------------------------------------------------------------------------
lazy_static::lazy_static! {
/// The process-global authentication manager, sharing the RBAC store that
/// the safety layer consults -- one user directory, not two.
static ref GLOBAL_AUTH: std::sync::Arc<crate::auth::AuthManager> =
std::sync::Arc::new(crate::auth::AuthManager::new(GLOBAL_RBAC.clone()));
/// The session established by the last successful `rbac_login`.
static ref LOGIN_SESSION: std::sync::Mutex<Option<LoginSession>> =
std::sync::Mutex::new(None);
}
#[derive(Clone)]
struct LoginSession {
session_id: String,
user_id: String,
username: String,
expires_at: String,
}
/// Read a password from the argument if given, and interactively otherwise.
/// Refuses to prompt when stdin is not a terminal: a prompt nobody can answer
/// is a hang, and silently reading a password off a pipe is worse.
fn password_arg(builtin: &str, args: &[Value], idx: usize, prompt: &str) -> Result<String> {
if let Some(p) = args.get(idx) {
return match p {
Value::Str(p) if !p.is_empty() && p.len() <= 1024 => Ok(p.clone()),
other => Err(crate::safety::bad_arg(
builtin,
"a password string of 1 to 1024 bytes",
other.type_name(),
)),
};
}
use std::io::IsTerminal;
if !std::io::stdin().is_terminal() {
return Err(crate::safety::arg_err(format!(
"{builtin}: no password given and stdin is not a terminal -- pass it as the second argument"
)));
}
let got = bi_input_read_password(vec![Value::Str(prompt.to_string())], None)?;
match got {
Value::Str(p) if !p.is_empty() => Ok(p),
_ => Err(crate::safety::arg_err(format!(
"{builtin}: no password entered"
))),
}
}
/// rbac_register(username, password?) - Create a credentialed user. With no
/// password argument, prompts twice and requires the two to match. The password
/// is stored as an Argon2id PHC string, never in the clear and never as a bare
/// digest.
fn bi_rbac_register(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let username = rbac_user_id("rbac_register", args.first())?;
let password = password_arg(
"rbac_register",
&args,
1,
&format!("Password for {username}: "),
)?;
if args.get(1).is_none() {
let again = password_arg("rbac_register", &[], 0, "Repeat password: ")?;
if again != password {
return Err(crate::safety::arg_err(
"rbac_register: the two passwords do not match",
));
}
}
let _ = global_rbac(); // install the shared store into the safety layer
if global_rbac().get_user_by_username(&username).is_some() {
return Err(crate::safety::bad_arg(
"rbac_register",
"a username that is not yet registered",
&format!("{username:?}, which is registered already"),
));
}
let user = GLOBAL_AUTH.register_user(&username, &password)?;
let mut rec = BTreeMap::new();
rec.insert("user".to_string(), Value::Str(user.username));
rec.insert("user_id".to_string(), Value::Str(user.id));
Ok(Value::Record(rec))
}
/// rbac_login(username, password?) - Authenticate and become that principal.
/// With no password argument, prompts (terminal only). On success the acting
/// principal is set to the authenticated user id, so RBAC grants apply to a
/// proven identity rather than an asserted one.
fn bi_rbac_login(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let username = rbac_user_id("rbac_login", args.first())?;
let password = password_arg(
"rbac_login",
&args,
1,
&format!("Password for {username}: "),
)?;
let _ = global_rbac();
let result = GLOBAL_AUTH.authenticate(crate::auth::Credential::Password {
username: username.clone(),
password,
})?;
if !result.success {
// Deliberately unspecific: which half was wrong is the attacker's
// question, and `AuthManager` has already recorded the detail in its
// own audit log. A coded refusal, so it is not mistaken for a crash.
return Err(crate::safety::policy_deny(
"rbac_login",
"rbac_login: invalid credentials",
"check the username and password; repeated failures are recorded in the audit trail",
));
}
let user = result.user.ok_or_else(|| {
crate::safety::policy_deny(
"rbac_login",
"rbac_login: authentication succeeded without a user record",
"this is an internal fault; do not retry with the same credentials",
)
})?;
let session = result.session.ok_or_else(|| {
crate::safety::policy_deny(
"rbac_login",
"rbac_login: authentication succeeded without a session",
"this is an internal fault; do not retry with the same credentials",
)
})?;
let expires_at = session.expires_at.to_rfc3339();
let mut login = LOGIN_SESSION
.lock()
.map_err(|_| crate::safety::lock_poisoned("rbac_login", "the login record"))?;
*login = Some(LoginSession {
session_id: session.id.clone(),
user_id: user.id.clone(),
username: user.username.clone(),
expires_at: expires_at.clone(),
});
drop(login);
crate::safety::set_principal(Some(user.id.clone()));
let mut rec = BTreeMap::new();
rec.insert("user".to_string(), Value::Str(user.username));
rec.insert("user_id".to_string(), Value::Str(user.id));
rec.insert("session".to_string(), Value::Str(session.id));
rec.insert("expires_at".to_string(), Value::Str(expires_at));
Ok(Value::Record(rec))
}
/// rbac_logout() - End the session and drop back to anonymous. Never gated:
/// giving up authority is the one privilege operation that cannot be an
/// escalation.
fn bi_rbac_logout(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// The principal is cleared before the login record is read, so a record that
// cannot be read still leaves the shell anonymous.
crate::safety::set_principal(None);
let was = LOGIN_SESSION
.lock()
.map_err(|_| crate::safety::lock_poisoned("rbac_logout", "the login record"))?
.take();
match was {
Some(s) => {
// The local principal was cleared above. If the auth store cannot
// end the session, say so rather than report a clean logout.
GLOBAL_AUTH.logout(&s.session_id).map_err(|e| {
crate::safety::io_fault(
"rbac_logout",
&format!(
"the local login was cleared, but the auth store did not end session {}: {e}",
s.session_id
),
"this shell is anonymous again; the session may stay valid in the auth store until it expires, so revoke it there",
)
})?;
Ok(Value::Bool(true))
}
None => Ok(Value::Bool(false)),
}
}
/// rbac_session() - The current login session as a record, or null when
/// anonymous or expired. Read-only, so an agent can always ask who it is.
fn bi_rbac_session(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// A poisoned table is not "anonymous": the session in it may still be live.
let current = LOGIN_SESSION
.lock()
.map_err(|_| crate::safety::lock_poisoned("rbac_session", "the login session"))?
.clone();
let Some(s) = current else {
return Ok(Value::Null);
};
// An expired session is not a session. Report it as gone and clear the
// principal, rather than leaving the guard trusting a dead identity.
if GLOBAL_AUTH.validate_session(&s.session_id).is_none() {
let _ = bi_rbac_logout(vec![], None);
return Ok(Value::Null);
}
let mut rec = BTreeMap::new();
rec.insert("user".to_string(), Value::Str(s.username));
rec.insert("user_id".to_string(), Value::Str(s.user_id));
rec.insert("session".to_string(), Value::Str(s.session_id));
rec.insert("expires_at".to_string(), Value::Str(s.expires_at));
Ok(Value::Record(rec))
}
/// audit_tail(n?) - Return the most recent n audit entries (default 20) from the
/// active hash-chained log as an array of records, so an agent can review what
/// it did and what was allowed/denied/approved. Read-only.
fn bi_audit_tail(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// A count that is not a count used to fall back silently to 20, and a
// huge one read the whole log into the agent's context. Both are refused.
const MAX_TAIL: usize = 10_000;
let n = match args.first() {
None => 20,
Some(Value::Int(k)) if (0..=MAX_TAIL as i64).contains(k) => *k as usize,
other => {
return Err(crate::safety::bad_arg(
"audit_tail",
&format!("an Int from 0 to {MAX_TAIL} (the default is 20)"),
other.map_or("nothing", Value::type_name),
))
}
};
// `read_audit_tail` answers [] for a missing or unreadable log alike. Look
// first, so "nothing was recorded" and "the log cannot be read" stay
// different answers.
let Some(path) = crate::safety::audit_path() else {
return Err(crate::safety::bad_state(
"audit_tail",
"no audit log is active",
"run in agent mode, or set IRONSHELL_AUDIT_LOG to a file",
));
};
match std::fs::File::open(&path) {
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Value::Array(Vec::new())),
Err(e) => return Err(crate::safety::fs_error("audit_tail", &path, &e)),
}
let entries: Vec<Value> = crate::safety::read_audit_tail(n)
.iter()
.map(Value::from_json)
.collect();
Ok(Value::Array(entries))
}
/// approve(token) - Grant a safety approval token in-process so a subsequent
/// guarded action bound to that token may proceed. Pairs with the
/// E_NEEDS_APPROVAL error returned by guarded builtins in agent mode (§7.2).
fn bi_approve(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let token = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(crate::safety::arg_err(
"approve: expected an approval token string",
))
}
};
// The approver must not be the party being approved (AS-2026-02's sibling,
// AS-2026-01).
//
// §5.3 grades `Destructive` and `Exec` as *approve* in agent mode, and §7.2
// documents the loop as `E_NEEDS_APPROVAL` -> `approve(token)` -> re-run.
// That loop is right for a human at a REPL and wrong for an agent, because
// the agent holds the token it was just refused with: demonstrated in
// `tests/approval_self_grant.rs`, where the denied call succeeds on retry.
// Against an agent that errs the second step is a real speed bump; against
// one that is adversarial or prompt-injected the `approve` cell is simply
// `allow`.
//
// So the in-band path is refused in agent mode only. Human mode is
// untouched, and the out-of-band channel the design already provides —
// `IRONSHELL_APPROVE` / `IRONSHELL_APPROVE_ALL`, set by whoever launches the
// agent — still works. No capability is removed that was not already
// reachable by someone outside the agent's control.
if crate::safety::current_mode() == crate::safety::Mode::Agent {
return Err(anyhow::Error::new(crate::safety::SafetyError {
code: crate::safety::ErrorCode::PolicyDeny,
message: "approve: an agent may not approve its own call".to_string(),
builtin: "approve".to_string(),
hint: "the token was issued to the caller that was refused, so spending it \
here would make the approval gate self-service; approve out of band \
with IRONSHELL_APPROVE=<token> (or IRONSHELL_APPROVE_ALL=1 for trusted \
automation), which is set by whoever launched the agent"
.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
}));
}
crate::safety::grant_approval(&token);
let mut rec = BTreeMap::new();
rec.insert("approved".to_string(), Value::Bool(true));
rec.insert("token".to_string(), Value::Str(token));
Ok(Value::Record(rec))
}
/// audit_verify(path?) - Verify the tamper-evident, hash-chained audit log
/// (§7.5). With no argument, verifies the currently active log. Returns a
/// record {valid, entries, path} or {valid: false, error, path}.
fn bi_audit_verify(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let path = match args.first() {
Some(Value::Str(s)) if !s.is_empty() => std::path::PathBuf::from(s),
// An argument that is present but not a path is an error, not a
// request to verify the active log instead.
Some(other) => {
return Err(crate::safety::bad_arg(
"audit_verify",
"a path string to a log file (or no argument for the active log)",
other.type_name(),
))
}
None => match crate::safety::audit_path() {
Some(p) => p,
None => {
return Err(crate::safety::bad_state(
"audit_verify",
"no audit log is active",
"pass the path of a log to verify, or run in agent mode, which keeps one",
))
}
},
};
let mut rec = BTreeMap::new();
rec.insert(
"path".to_string(),
Value::Str(path.to_string_lossy().to_string()),
);
// Whether the chain is keyed says how much a `valid` answer proves: an
// unkeyed chain can be rewritten end to end by anyone who can write the
// file, so it shows corruption, not tampering.
rec.insert(
"keyed".to_string(),
Value::Bool(crate::safety::audit_key().is_some()),
);
match crate::safety::verify_audit(&path) {
// A log with no entries is what a truncation leaves behind, so it
// attests to nothing and must not read as valid.
Ok(0) => {
rec.insert("valid".to_string(), Value::Bool(false));
rec.insert("entries".to_string(), Value::Int(0));
rec.insert(
"error".to_string(),
Value::Str(
"the log holds no entries, so there is nothing it attests to".to_string(),
),
);
}
Ok(n) => {
rec.insert("valid".to_string(), Value::Bool(true));
rec.insert("entries".to_string(), Value::Int(n as i64));
}
Err(e) => {
rec.insert("valid".to_string(), Value::Bool(false));
rec.insert("error".to_string(), Value::Str(e));
}
}
Ok(Value::Record(rec))
}
/// now() - Get current Unix timestamp in seconds
fn bi_now(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now().duration_since(UNIX_EPOCH).map_err(|_| {
crate::safety::io_fault(
"now",
"the system clock reads earlier than 1970",
"set the system clock, then retry",
)
})?;
Ok(Value::Int(duration.as_secs() as i64))
}
/// sort_by(fn, direction?) - Sort array by key function
/// Usage: arr | sort_by(fn(x) => x.name) or arr | sort_by(fn(x) => x.size, "desc")
fn bi_sort_by(args: Vec<Value>, input: Option<Value>, env: &mut Env) -> Result<Value> {
let arr = input
.as_ref()
.and_then(|v| match v {
Value::Array(a) => Some(a.as_slice()),
_ => None,
})
.or_else(|| {
args.first().and_then(|v| match v {
Value::Array(a) => Some(a.as_slice()),
_ => None,
})
})
.ok_or_else(|| crate::safety::arg_err("sort_by requires array input"))?;
let lambda = args.iter().find_map(|v| match v {
Value::Lambda(l) => Some(l),
_ => None,
});
let descending = args.iter().any(|v| match v {
Value::Str(s) => s == "desc" || s == "descending",
_ => false,
});
// A bare field name is accepted as the sort key: `sort_by("size")` rather
// than `sort_by(fn(f) => f.size)`.
//
// Added after hitting this while using the shell as an agent. The string
// form is what most callers reach for first, and it previously failed
// twice over: the argument was silently ignored (only "desc"/"descending"
// were recognised), and the call then errored for want of a lambda — one
// wasted round-trip on a call that read as obviously correct.
//
// "desc"/"descending" stay reserved as the direction, so a field actually
// named that must use the lambda form. Stated rather than papered over.
let field = args.iter().find_map(|v| match v {
Value::Str(s) if s != "desc" && s != "descending" => Some(s.clone()),
_ => None,
});
if lambda.is_none() && field.is_none() {
return Err(crate::safety::bad_arg(
"sort_by",
"a key: either a field name (\"size\") or a lambda (fn(x) => x.size)",
"neither",
));
}
// Extract keys for each element
let mut keyed: Vec<(Value, Value)> = arr
.iter()
.map(|item| {
let key = match (&lambda, &field) {
(Some(l), _) => call_lambda(l, &[item.clone()], env)?,
(None, Some(f)) => match item {
Value::Record(m) => m.get(f).cloned().unwrap_or(Value::Null),
_ => Value::Null,
},
(None, None) => Value::Null,
};
Ok((key, item.clone()))
})
.collect::<Result<_>>()?;
// Sort by keys
keyed.sort_by(|(a, _), (b, _)| {
let cmp = sort_compare_values(a, b);
if descending {
cmp.reverse()
} else {
cmp
}
});
let result: Vec<Value> = keyed.into_iter().map(|(_, v)| v).collect();
Ok(Value::Array(result))
}
/// Helper to compare two values for sorting
fn sort_compare_values(a: &Value, b: &Value) -> std::cmp::Ordering {
use std::cmp::Ordering;
match (a, b) {
(Value::Int(x), Value::Int(y)) => x.cmp(y),
(Value::Float(x), Value::Float(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal),
(Value::Int(x), Value::Float(y)) => (*x as f64).partial_cmp(y).unwrap_or(Ordering::Equal),
(Value::Float(x), Value::Int(y)) => x.partial_cmp(&(*y as f64)).unwrap_or(Ordering::Equal),
(Value::Str(x), Value::Str(y)) => x.cmp(y),
(Value::Bool(x), Value::Bool(y)) => x.cmp(y),
_ => Ordering::Equal,
}
}
/// save_json(path, value?) - Save value as JSON to file
/// Usage: data | save_json("output.json") or save_json("output.json", data)
fn bi_save_json(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
if args.is_empty() {
return Err(crate::safety::arg_err("save_json requires a file path"));
}
let path = match &args[0] {
Value::Str(s) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"save_json",
"path: String",
other.type_name(),
))
}
};
// Piped: the value is the input and the path is the only argument.
// Unpiped: path, then value. A piped value used to be ignored whenever a
// second argument was present.
let value = match (input, args.len()) {
(Some(_), n) if n > 1 => {
return Err(crate::safety::bad_arg(
"save_json",
"with piped input, the path only",
&format!("{n} arguments"),
))
}
(Some(v), _) => v,
(None, 2) => args[1].clone(),
(None, 1) => return Err(crate::safety::arg_err("save_json requires a value to save")),
(None, n) => {
return Err(crate::safety::bad_arg(
"save_json",
"a path and a value (or a path and piped input)",
&format!("{n} arguments"),
))
}
};
// The write is jailed like every other local write: a relative path lands
// in the workspace, and an absolute path outside it is refused. Before this
// call nothing checked the path, so an agent could write any file.
let path = guard_local_write("save_json", &path)?;
// Convert Value to JSON using existing function
let json = value_to_json(value);
let json_str = serde_json::to_string_pretty(&json).map_err(|e| {
crate::safety::arg_err(format!(
"save_json: the value cannot be written as JSON: {e}"
))
})?;
// An existing file is replaced, and the record says so: a save that quietly
// destroyed an earlier file is the surprise to avoid.
let overwrote = std::path::Path::new(&path).exists();
crate::tx::snapshot(&path); // record pre-save state if a transaction is active
write_file_atomically("save_json", &path, json_str.as_bytes())?;
let mut record = BTreeMap::new();
record.insert("path".to_string(), Value::Str(path));
record.insert("bytes".to_string(), Value::Int(json_str.len() as i64));
record.insert("overwrote".to_string(), Value::Bool(overwrote));
record.insert("success".to_string(), Value::Bool(true));
Ok(Value::Record(record))
}
/// mcp_server_start(config) - Start a custom MCP server
/// Usage: mcp_server_start({name: "fs", type: "builtin", config: {...}})
fn bi_mcp_server_start(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("mcp_server_start", &input)?;
let config = match args.first() {
Some(Value::Record(r)) => r,
Some(other) => {
return Err(crate::safety::bad_arg(
"mcp_server_start",
"config: Record",
other.type_name(),
))
}
None => {
return Err(crate::safety::bad_arg(
"mcp_server_start",
"config: Record",
"nothing",
))
}
};
// Keys this declaration does not describe are refused rather than dropped,
// and a name or type that is not text is refused rather than replaced by a
// default ("default", "builtin").
for key in config.keys() {
if !matches!(key.as_str(), "name" | "type" | "endpoint" | "config") {
return Err(crate::safety::bad_arg(
"mcp_server_start",
"config keys name, type, endpoint, config",
key,
));
}
}
let text_field = |key: &str, default: &str| -> Result<String> {
match config.get(key) {
None => Ok(default.to_string()),
Some(v) => Ok(expect_string("mcp_server_start", v)?.to_string()),
}
};
let name = text_field("name", "default")?;
let server_type = text_field("type", "builtin")?;
// This reported status "started" with an endpoint on a port derived from
// the clock, and started nothing. It now says what it is: a declaration.
// `ish mcp serve` is the real server.
let mut result = BTreeMap::new();
result.insert("name".to_string(), Value::Str(name));
result.insert("type".to_string(), Value::Str(server_type));
if let Some(ep) = config.get("endpoint") {
result.insert("endpoint".to_string(), ep.clone());
}
result.insert(
"status".to_string(),
Value::Str("declared (not started)".to_string()),
);
result.insert(
"hint".to_string(),
Value::Str("run `ish mcp serve` to start an MCP server".to_string()),
);
if let Some(inner_config) = config.get("config") {
result.insert("config".to_string(), inner_config.clone());
}
Ok(Value::Record(result))
}
/// Prefix for an MCP server's tools in an agent's tool list. Without an endpoint
/// it is stripped from a builtin name; with one, a name that carries it is a
/// server tool only when the server's own tools/list names it.
const AGENT_MCP_PREFIX: &str = "mcp:";
/// A tool name the endpoint may serve: ASCII letters, digits, `_`, `-` and `.`,
/// at most 128 bytes. The name goes into a request path, so it is checked first.
fn is_plain_mcp_tool_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 128
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
}
/// Split the requested tool names between the endpoint's tools and the builtins.
/// Without an endpoint (`endpoint_tools` is None) every name is a builtin with
/// any `mcp:` prefix stripped. With one, `mcp:<t>` is a server tool only when `t`
/// is a plain name the server lists; any other `mcp:` name stays as written, so
/// it is refused as unknown rather than run as a builtin of the same name.
/// Returns (server tool names, builtin names).
fn split_agent_tool_names(
requested: &[String],
endpoint_tools: Option<&[String]>,
) -> (Vec<String>, Vec<String>) {
let mut remote: Vec<String> = Vec::new();
let mut builtin: Vec<String> = Vec::new();
for name in requested {
let raw = name.as_str();
let Some(served) = endpoint_tools else {
builtin.push(
raw.strip_prefix(AGENT_MCP_PREFIX)
.unwrap_or(raw)
.to_string(),
);
continue;
};
match raw.strip_prefix(AGENT_MCP_PREFIX) {
Some(t) if is_plain_mcp_tool_name(t) && served.iter().any(|s| s == t) => {
if !remote.iter().any(|r| r == t) {
remote.push(t.to_string());
}
}
_ => builtin.push(raw.to_string()),
}
}
(remote, builtin)
}
/// A tool served by the endpoint's MCP server. The agent sees it as `mcp:<tool>`.
/// Each call is guarded as a network effect and sent with the MCP client; the
/// client (and its connection) is dropped when the agent run ends.
struct AgentMcpTool {
name: String,
server_tool: String,
description: String,
endpoint: String,
client: std::sync::Arc<crate::ai::mcp::McpClient>,
}
impl crate::ai::agents::Tool for AgentMcpTool {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
&self.description
}
fn call(&self, input: &str, _env: &mut Env) -> Result<Value> {
crate::safety::guard(crate::safety::GuardCtx {
builtin: "agent_with_mcp",
effect: crate::safety::Effect::Network,
what: "request",
targets: vec![format!("{}/{}", self.endpoint, self.server_tool)],
blast_radius: serde_json::json!({ "tool": self.name, "endpoint": self.endpoint }),
reversible: false,
fs_paths: false,
})?;
let arguments: serde_json::Value = serde_json::from_str(input)
.unwrap_or_else(|_| serde_json::Value::String(input.to_string()));
self.client.validate_input(&self.server_tool, &arguments)?;
let out = self.client.call_tool(&self.server_tool, input)?;
Ok(Value::Str(out))
}
}
/// Connect to an MCP server the way `mcp_connect` does: only an endpoint that
/// `detect_mcp_servers` found (its loopback probe) is matched, and no other
/// address is contacted. Returns the client and the tools the server lists.
fn connect_agent_mcp(
endpoint: &str,
) -> Result<(
crate::ai::mcp::McpClient,
Vec<crate::ai::mcp::McpToolSchema>,
)> {
let detected = crate::ai::detect_mcp_servers()
.iter()
.any(|s| s.endpoint == endpoint);
if !detected {
return Err(crate::safety::not_found(
"agent_with_mcp",
"detected MCP server",
endpoint,
));
}
let client = crate::ai::mcp::McpClient::new(endpoint);
let schemas = client.discover_tools()?;
Ok((client, schemas))
}
/// agent_with_mcp(goal, tools, endpoint?) - Run a model-driven agent over named
/// tools. Builtin tools are in-process and every call passes the normal dispatch
/// guard. With an endpoint, the MCP server at it is connected as `mcp_connect`
/// connects (only a loopback server that `detect_mcp_servers` finds), and each
/// `mcp:<name>` it lists is a tool; each such call is guarded as a network
/// effect. The model call itself is a Network effect, so it is guarded here and
/// rate limited like `agent`. The run is `completed` only when the model gives a
/// final answer.
/// Usage: agent_with_mcp("Task description", ["mcp:read_file", "mcp:list_dir"])
fn bi_agent_with_mcp(args: Vec<Value>, input: Option<Value>, env: &mut Env) -> Result<Value> {
refuse_piped_subject("agent_with_mcp", &input)?;
if args.len() < 2 {
return Err(crate::safety::arg_err(
"agent_with_mcp requires goal and tools array",
));
}
let goal_text = collab_text_arg("agent_with_mcp", "goal", args.first(), 8192)?;
let goal = crate::security::validate_ai_prompt(&goal_text).map_err(|e| {
crate::safety::bad_arg(
"agent_with_mcp",
"goal: a prompt that passes validation",
&e.to_string(),
)
})?;
let names: Vec<String> = match args.get(1) {
Some(Value::Array(items)) => {
if items.len() > 32 {
return Err(crate::safety::budget_exceeded(
"agent_with_mcp",
format!(
"agent_with_mcp: {} tools requested; at most 32",
items.len()
),
"give the agent fewer tools",
));
}
let mut out = Vec::with_capacity(items.len());
for item in items {
match item {
Value::Str(s) => out.push(s.clone()),
other => {
return Err(crate::safety::bad_arg(
"agent_with_mcp",
"tools: an array of tool names (String)",
other.type_name(),
))
}
}
}
out
}
other => {
return Err(crate::safety::bad_arg(
"agent_with_mcp",
"tools: an array of tool names (String)",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
// The endpoint is optional. When it is given, the server is connected before
// any tool name is checked, because its tools/list decides what `mcp:` names mean.
let endpoint: Option<String> = match args.get(2) {
None | Some(Value::Null) => None,
Some(v) => Some(expect_string("agent_with_mcp", v)?.to_string()),
};
let connection = match endpoint.as_deref() {
Some(ep) => Some(connect_agent_mcp(ep)?),
None => None,
};
let server_tools: Option<Vec<String>> = connection
.as_ref()
.map(|(_, schemas)| schemas.iter().map(|t| t.name.clone()).collect());
let (remote, base_tools) = split_agent_tool_names(&names, server_tools.as_deref());
let base_refs: Vec<&str> = base_tools.iter().map(String::as_str).collect();
// The agent's own resolver answers for any name, so the check is against the
// dispatcher: a name it does not serve is refused, not left to fail mid-run.
for name in &base_refs {
if !is_dispatched(name) {
return Err(crate::safety::not_found("agent_with_mcp", "tool", name));
}
}
crate::security::check_rate_limit("agent_with_mcp", 10, Duration::from_secs(60)).map_err(
|e| {
crate::safety::budget_exceeded(
"agent_with_mcp",
format!("agent_with_mcp: {e}"),
"wait a minute before the next agent run",
)
},
)?;
let model =
std::env::var("IRONSHELL_AGENT_MODEL_URI").unwrap_or_else(|_| "default".to_string());
crate::safety::guard(crate::safety::GuardCtx {
builtin: "agent_with_mcp",
effect: crate::safety::Effect::Network,
what: "request",
targets: vec![model.clone()],
blast_radius: serde_json::json!({
"model": model,
"max_steps": 10,
"tools": names.clone(),
"endpoint": endpoint.clone(),
}),
reversible: false,
fs_paths: false,
})?;
let mut tools = crate::ai::agents::ToolRegistry::with_builtins().resolve_many(&base_refs);
if let (Some((client, schemas)), Some(ep)) = (connection, endpoint.as_deref()) {
let client = std::sync::Arc::new(client);
for name in &remote {
if let Some(schema) = schemas.iter().find(|s| &s.name == name) {
tools.push(Box::new(AgentMcpTool {
name: format!("{AGENT_MCP_PREFIX}{}", schema.name),
server_tool: schema.name.clone(),
description: schema.description.clone(),
endpoint: ep.to_string(),
client: std::sync::Arc::clone(&client),
}));
}
}
}
let max_steps = 10;
let result =
crate::ai::agents::run_sync_with_tools(&goal, tools, max_steps, env).map_err(|e| {
crate::safety::tool_failed("agent_with_mcp", "model backend", None, &e.to_string())
})?;
if result.starts_with("(incomplete)") {
return Err(crate::safety::budget_exceeded(
"agent_with_mcp",
format!("agent_with_mcp: the agent used its {max_steps} steps without a final answer"),
"narrow the goal or the tool list, then run it again",
));
}
let mut agent_record = BTreeMap::new();
agent_record.insert("goal".to_string(), Value::Str(goal));
agent_record.insert(
"tools".to_string(),
Value::Array(names.into_iter().map(Value::Str).collect()),
);
agent_record.insert("type".to_string(), Value::Str("mcp_agent".to_string()));
agent_record.insert("result".to_string(), Value::Str(result));
agent_record.insert("status".to_string(), Value::Str("completed".to_string()));
Ok(Value::Record(agent_record))
}
#[cfg(test)]
mod agent_mcp_endpoint_tests {
use super::{is_plain_mcp_tool_name, split_agent_tool_names};
fn names(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
#[test]
fn server_tool_names_must_be_plain() {
assert!(is_plain_mcp_tool_name("read_file"));
assert!(is_plain_mcp_tool_name("git.log-1"));
assert!(!is_plain_mcp_tool_name(""));
assert!(!is_plain_mcp_tool_name("../admin"));
assert!(!is_plain_mcp_tool_name("a/b"));
assert!(!is_plain_mcp_tool_name("a b"));
assert!(!is_plain_mcp_tool_name(&"x".repeat(129)));
}
#[test]
fn without_an_endpoint_every_name_is_a_builtin() {
let (remote, builtin) = split_agent_tool_names(&names(&["mcp:ls", "pwd"]), None);
assert!(remote.is_empty());
assert_eq!(builtin, names(&["ls", "pwd"]));
}
#[test]
fn only_listed_server_tools_are_remote() {
let server = names(&["read_file", "bad/name"]);
let requested = names(&["mcp:read_file", "mcp:bad/name", "mcp:rm", "pwd"]);
let (remote, builtin) = split_agent_tool_names(&requested, Some(&server));
assert_eq!(remote, names(&["read_file"]));
// Unlisted `mcp:` names are kept as written, so they are refused as
// unknown and never run as the builtin of the same name.
assert_eq!(builtin, names(&["mcp:bad/name", "mcp:rm", "pwd"]));
}
#[test]
fn an_empty_server_list_serves_nothing() {
let (remote, builtin) = split_agent_tool_names(&names(&["mcp:ls"]), Some(&[]));
assert!(remote.is_empty());
assert_eq!(builtin, names(&["mcp:ls"]));
}
}
/// each(fn) - Like map but for side effects, returns original array
/// Usage: arr | each(fn(x) => print(x))
fn bi_each(args: Vec<Value>, input: Option<Value>, env: &mut Env) -> Result<Value> {
let arr = input
.as_ref()
.and_then(|v| match v {
Value::Array(a) => Some(a.clone()),
_ => None,
})
.or_else(|| {
args.first().and_then(|v| match v {
Value::Array(a) => Some(a.clone()),
_ => None,
})
})
.ok_or_else(|| crate::safety::arg_err("each requires array input"))?;
let lambda = args
.iter()
.find_map(|v| match v {
Value::Lambda(l) => Some(l),
_ => None,
})
.ok_or_else(|| crate::safety::arg_err("each requires a lambda function"))?;
// For side effects. The results are discarded, the errors are not: this
// was `let _ = call_lambda(..)`, so a side effect that failed (a write
// refused, a division by zero) left `each` reporting success.
for item in &arr {
call_lambda(lambda, &[item.clone()], env)?;
}
// Return original array
Ok(Value::Array(arr))
}
/// in(value, collection) - Check if value is in collection
/// Usage: "x" | in(["a", "b", "x"]) or in("x", ["a", "b", "x"])
fn bi_in(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
// Membership: `in(needle, haystack)`, or `needle | in(haystack)`. Piped, the
// needle is the input and the haystack is the one argument.
let (needle, haystack) = match input {
Some(inp) => {
if args.len() != 1 {
return Err(crate::safety::bad_arg(
"in",
"with piped input, exactly one argument: the collection",
&format!("{} arguments", args.len()),
));
}
(inp, args[0].clone())
}
None => {
if args.len() != 2 {
return Err(crate::safety::bad_arg(
"in",
"a value and a collection",
&format!("{} arguments", args.len()),
));
}
(args[0].clone(), args[1].clone())
}
};
// A haystack that cannot hold anything, or a needle of the wrong type for a
// string or record, used to answer false. That is the same answer as "not
// present", so a mistyped call looked like a true negative. Refuse both.
let found = match &haystack {
Value::Array(arr) => arr.iter().any(|v| values_equal(&needle, v)),
Value::Str(s) => match &needle {
Value::Str(n) => s.contains(n.as_str()),
other => {
return Err(crate::safety::bad_arg(
"in",
"a String needle to search a String",
other.type_name(),
))
}
},
Value::Record(rec) => match &needle {
Value::Str(key) => rec.contains_key(key),
other => {
return Err(crate::safety::bad_arg(
"in",
"a String key to search a Record",
other.type_name(),
))
}
},
other => {
return Err(crate::safety::bad_arg(
"in",
"a collection: Array, String, or Record",
other.type_name(),
))
}
};
Ok(Value::Bool(found))
}
/// Check if two values are equal
fn values_equal(a: &Value, b: &Value) -> bool {
match (a, b) {
(Value::Null, Value::Null) => true,
(Value::Bool(x), Value::Bool(y)) => x == y,
(Value::Int(x), Value::Int(y)) => x == y,
// Exact comparison. An absolute epsilon made 1e-300 and 2e-300 equal and
// missed large values that differ in the last bit.
(Value::Float(x), Value::Float(y)) => x == y,
(Value::Int(x), Value::Float(y)) | (Value::Float(y), Value::Int(x)) => *x as f64 == *y,
(Value::Str(x), Value::Str(y)) => x == y,
(Value::Array(x), Value::Array(y)) => {
x.len() == y.len() && x.iter().zip(y.iter()).all(|(a, b)| values_equal(a, b))
}
(Value::Record(x), Value::Record(y)) => {
x.len() == y.len()
&& x.iter()
.zip(y.iter())
.all(|((ka, va), (kb, vb))| ka == kb && values_equal(va, vb))
}
_ => false,
}
}
// ===================== Enterprise Feature Implementations =====================
/// role_create(name, permissions, description?) - Create a new RBAC role
fn bi_role_create(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
// With the name piped in, the arguments are the parameters; called
// directly, the name is the first. This read permissions from args[1]
// either way, so a piped name took the description as its permissions.
let piped = input.is_some();
let name = input
.or_else(|| args.first().cloned())
.map(|v| key_arg("role_create", v))
.transpose()?
.ok_or_else(|| crate::safety::arg_err("role_create: missing 'name'"))?;
let params = if piped {
&args[..]
} else {
args.get(1..).unwrap_or(&[])
};
// Every entry must be a {resource, actions} record. Malformed entries were
// dropped silently, so a role could be created with fewer permissions
// than asked for and still report "created" -- the wrong failure mode for
// an access-control builtin.
let bad_permission = |got: &Value| {
crate::safety::bad_arg(
"role_create",
"permissions: an array of {resource: String, actions: [String]} records",
&got.to_display_string(),
)
};
let permissions = match params.first() {
None => Vec::new(),
Some(Value::Array(arr)) => {
let mut out = Vec::with_capacity(arr.len());
for p in arr {
let Value::Record(rec) = p else {
return Err(bad_permission(p));
};
let Some(Value::Str(resource)) = rec.get("resource") else {
return Err(bad_permission(p));
};
let actions = match rec.get("actions") {
None => Vec::new(),
Some(Value::Array(a)) => a
.iter()
.map(|v| match v {
Value::Str(s) => Ok(s.clone()),
_ => Err(bad_permission(p)),
})
.collect::<Result<Vec<_>>>()?,
Some(_) => return Err(bad_permission(p)),
};
out.push(Permission {
resource: resource.clone(),
actions,
});
}
out
}
Some(other) => return Err(bad_permission(other)),
};
let description = params
.get(1)
.map(|v| v.to_display_string())
.unwrap_or_default();
let role = Role {
name: name.clone(),
permissions,
description,
created_at: std::time::Instant::now(),
};
RBAC_ROLES.write().unwrap().insert(name.clone(), role);
let mut result = BTreeMap::new();
result.insert("status".to_string(), Value::Str("created".to_string()));
result.insert("role".to_string(), Value::Str(name));
Ok(Value::Record(result))
}
/// role_delete(name) - Delete an RBAC role
fn bi_role_delete(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let name = input
.or_else(|| args.first().cloned())
.map(|v| key_arg("role_delete", v))
.transpose()?
.ok_or_else(|| crate::safety::arg_err("role_delete: missing 'name'"))?;
let existed = RBAC_ROLES.write().unwrap().remove(&name).is_some();
if !existed {
return Err(crate::safety::not_found("role_delete", "role", &name));
}
// Take the role from every holder as well. Left in place, a role created
// again under the same name would silently give back everyone it had.
let mut unassigned: i64 = 0;
for roles in USER_ROLES.write().unwrap().values_mut() {
let before = roles.len();
roles.retain(|r| r != &name);
unassigned += (before - roles.len()) as i64;
}
let mut result = BTreeMap::new();
result.insert("status".to_string(), Value::Str("deleted".to_string()));
result.insert("role".to_string(), Value::Str(name));
result.insert("users_unassigned".to_string(), Value::Int(unassigned));
Ok(Value::Record(result))
}
/// A name argument for the role store: a non-empty string of at most 256
/// bytes with no control characters. The old form took any value and stored
/// its display text, so `1` and `"1"` were the same user.
fn role_name_arg(builtin: &str, v: Option<&Value>, what: &str) -> Result<String> {
match v {
Some(Value::Str(s))
if !s.is_empty() && s.len() <= 256 && !s.chars().any(char::is_control) =>
{
Ok(s.clone())
}
other => Err(crate::safety::bad_arg(
builtin,
&format!("{what}: a string of 1 to 256 characters with no control characters"),
other.map_or("nothing", Value::type_name),
)),
}
}
/// role_grant(user, role) - Grant a role to a user
fn bi_role_grant(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let user = role_name_arg("role_grant", args.first(), "a user name")?;
let role = role_name_arg("role_grant", args.get(1), "a role name")?;
if !RBAC_ROLES.read().unwrap().contains_key(&role) {
return Err(crate::safety::not_found("role_grant", "role", &role));
}
// Holding a role twice is holding it once. A repeated grant reports
// "unchanged" rather than a fresh "granted" for an edit that did nothing.
let mut user_roles = USER_ROLES.write().unwrap();
let held = user_roles.entry(user.clone()).or_default();
let changed = !held.contains(&role);
if changed {
held.push(role.clone());
}
let mut result = BTreeMap::new();
result.insert(
"status".to_string(),
Value::Str(if changed { "granted" } else { "unchanged" }.to_string()),
);
result.insert("user".to_string(), Value::Str(user));
result.insert("role".to_string(), Value::Str(role));
Ok(Value::Record(result))
}
/// role_revoke(user, role) - Revoke a role from a user. A role the user does
/// not hold is an error, not a quiet "not_found" status: nothing was revoked.
fn bi_role_revoke(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let user = role_name_arg("role_revoke", args.first(), "a user name")?;
let role = role_name_arg("role_revoke", args.get(1), "a role name")?;
let mut user_roles = USER_ROLES.write().unwrap();
let revoked = user_roles.get_mut(&user).is_some_and(|roles| {
let before = roles.len();
roles.retain(|r| r != &role);
roles.len() < before
});
if !revoked {
return Err(crate::safety::not_found(
"role_revoke",
"grant",
&format!("{role} held by {user}"),
));
}
let mut result = BTreeMap::new();
result.insert("status".to_string(), Value::Str("revoked".to_string()));
result.insert("user".to_string(), Value::Str(user));
result.insert("role".to_string(), Value::Str(role));
Ok(Value::Record(result))
}
/// user_roles(user) - Get roles assigned to a user
fn bi_user_roles(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let user = input
.or_else(|| args.first().cloned())
.map(|v| key_arg("user_roles", v))
.transpose()?
.ok_or_else(|| crate::safety::arg_err("user_roles: missing 'user'"))?;
let roles = USER_ROLES
.read()
.unwrap()
.get(&user)
.cloned()
.unwrap_or_default();
Ok(Value::Array(roles.into_iter().map(Value::Str).collect()))
}
/// check_permission(user, resource, action) - Check if user has permission
fn bi_check_permission(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// Each argument must be the string it names. Display text made `1`, `"1"`
// and `[1]` the same user, so a non-string could be granted by name.
let user = role_name_arg("check_permission", args.first(), "a user name")?;
let resource = role_name_arg("check_permission", args.get(1), "a resource")?;
let action = role_name_arg("check_permission", args.get(2), "an action")?;
let user_roles_guard = USER_ROLES.read().unwrap();
let roles_guard = RBAC_ROLES.read().unwrap();
let user_role_names = user_roles_guard.get(&user).cloned().unwrap_or_default();
let has_permission = user_role_names.iter().any(|role_name| {
if let Some(role) = roles_guard.get(role_name) {
role.permissions.iter().any(|p| {
(p.resource == "*" || p.resource == resource)
&& (p.actions.contains(&"*".to_string())
|| p.actions.iter().any(|a| a == &action))
})
} else {
false
}
});
Ok(Value::Bool(has_permission))
}
/// roles_list() - List all defined roles
fn bi_roles_list(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let roles = RBAC_ROLES.read().unwrap();
let result: Vec<Value> = roles
.values()
.map(|role| {
let mut rec = BTreeMap::new();
rec.insert("name".to_string(), Value::Str(role.name.clone()));
rec.insert(
"description".to_string(),
Value::Str(role.description.clone()),
);
rec.insert(
"permissions".to_string(),
Value::Int(role.permissions.len() as i64),
);
Value::Record(rec)
})
.collect();
Ok(Value::Array(result))
}
/// audit_log(action, resource, details?, severity?) - Log an audit entry
fn bi_audit_log(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// Entries here are the agent's own assertions about what it did, so they
// are bounded and kept in the same chained log as the shell's decisions,
// labelled `asserted`, rather than sitting in memory where nothing can
// say who put them there.
const MAX_ENTRIES: usize = 10_000;
if args.len() < 2 {
return Err(crate::safety::arg_err(
"audit_log: requires action and resource arguments",
));
}
let action = audit_text_arg("audit_log", args.first(), "an action", false)?;
let resource = audit_text_arg("audit_log", args.get(1), "a resource", false)?;
let details = match args.get(2) {
None => String::new(),
Some(v) => audit_text_arg("audit_log", Some(v), "details", true)?,
};
let severity = match args.get(3) {
None => AuditSeverity::Info,
Some(Value::Str(s)) => match s.as_str() {
"info" => AuditSeverity::Info,
"warning" => AuditSeverity::Warning,
"critical" => AuditSeverity::Critical,
_ => {
return Err(crate::safety::bad_arg(
"audit_log",
"a severity of info, warning or critical",
&format!("{s:?}"),
))
}
},
Some(other) => {
return Err(crate::safety::bad_arg(
"audit_log",
"a severity of info, warning or critical",
other.type_name(),
))
}
};
if AUDIT_LOG.read().unwrap().len() >= MAX_ENTRIES {
return Err(crate::safety::budget_exceeded(
"audit_log",
format!("the audit list holds {MAX_ENTRIES} entries and takes no more"),
"entries are not dropped to make room; the chained audit log keeps the full record",
));
}
let id = format!(
"audit_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let user = crate::safety::current_principal()
.or_else(|| std::env::var("USER").ok())
.unwrap_or_else(|| "system".to_string());
// The chained log is what `audit_verify` checks. An assertion that lands
// there is attributable and tamper-evident; `decision: asserted` says it
// is a claim, not a decision the shell made. A required write that fails
// is an error here, not a "logged" result: the entry is not kept in memory
// either, so nothing reports it as logged.
if let Err(e) = crate::safety::audit(
"audit_log",
crate::safety::Effect::WriteLocal,
"asserted",
&resource,
serde_json::json!({
"id": id,
"action": action,
"details": details,
"severity": severity.as_str(),
"user": user,
}),
) {
return Err(crate::safety::io_fault(
"audit_log",
&format!("the audit entry was not logged: {e}"),
"the required audit log could not be written (IRONSHELL_AUDIT_REQUIRED is set); fix the audit log path or unset IRONSHELL_AUDIT_REQUIRED, then retry",
));
}
let entry = AuditEntry {
id: id.clone(),
timestamp: std::time::Instant::now(),
user,
action,
resource,
details,
severity,
success: true,
};
AUDIT_LOG
.write()
.map_err(|_| crate::safety::lock_poisoned("audit_log", "the in-process audit list"))?
.push(entry);
let mut result = BTreeMap::new();
result.insert("status".to_string(), Value::Str("logged".to_string()));
result.insert("id".to_string(), Value::Str(id));
Ok(Value::Record(result))
}
/// A text field of an audit entry. Control characters are refused: a field
/// with a newline or a tab is how a reader of the plain-text view is misled
/// about where one entry ends and the next begins.
fn audit_text_arg(
builtin: &str,
v: Option<&Value>,
what: &str,
allow_empty: bool,
) -> Result<String> {
const MAX: usize = 4096;
match v {
Some(Value::Str(s))
if (allow_empty || !s.is_empty())
&& s.len() <= MAX
&& !s.chars().any(char::is_control) =>
{
Ok(s.clone())
}
other => Err(crate::safety::bad_arg(
builtin,
&format!(
"{what}: a string of {}{MAX} bytes or fewer with no control characters",
if allow_empty { "" } else { "1 to " }
),
other.map_or("nothing", Value::type_name),
)),
}
}
/// audit_query(filter?) - Query audit log entries
fn bi_audit_query(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// A filter that is not a record, or names a field the log does not have,
// used to be ignored, and the query then answered with everything. That
// reads as a filtered answer, so it is refused.
let filter = match args.first() {
None => None,
Some(Value::Record(rec)) => {
if let Some(bad) = rec
.keys()
.find(|k| !matches!(k.as_str(), "action" | "resource"))
{
return Err(crate::safety::bad_arg(
"audit_query",
"a filter with only the fields action and resource",
&format!("field {bad:?}"),
));
}
Some(rec.clone())
}
Some(other) => {
return Err(crate::safety::bad_arg(
"audit_query",
"a filter Record such as {action: \"login\"}",
other.type_name(),
))
}
};
let log = AUDIT_LOG.read().unwrap();
let entries: Vec<Value> = log
.iter()
.filter(|e| {
if let Some(ref f) = filter {
let action_match = f
.get("action")
.map(|v| e.action.contains(&v.to_display_string()))
.unwrap_or(true);
let resource_match = f
.get("resource")
.map(|v| e.resource.contains(&v.to_display_string()))
.unwrap_or(true);
action_match && resource_match
} else {
true
}
})
.map(|e| {
let mut rec = BTreeMap::new();
rec.insert("id".to_string(), Value::Str(e.id.clone()));
rec.insert("user".to_string(), Value::Str(e.user.clone()));
rec.insert("action".to_string(), Value::Str(e.action.clone()));
rec.insert("resource".to_string(), Value::Str(e.resource.clone()));
rec.insert(
"severity".to_string(),
Value::Str(e.severity.as_str().to_string()),
);
rec.insert("success".to_string(), Value::Bool(e.success));
Value::Record(rec)
})
.collect();
Ok(Value::Array(entries))
}
/// audit_export(format?) - Export audit log
fn bi_audit_export(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let format = args
.first()
.map(|v| v.to_display_string())
.unwrap_or_else(|| "json".to_string());
// The format was echoed back and otherwise ignored: audit_export("csv")
// answered {format: "csv"} around the same records. Records are the only
// shape produced, so only "json" is honest.
if format != "json" {
return Err(crate::safety::bad_arg(
"audit_export",
"format \"json\" (the only one implemented; the entries are records)",
&format!("{format:?}"),
));
}
let log = AUDIT_LOG.read().unwrap();
let entries: Vec<Value> = log
.iter()
.map(|e| {
let mut rec = BTreeMap::new();
rec.insert("id".to_string(), Value::Str(e.id.clone()));
rec.insert("user".to_string(), Value::Str(e.user.clone()));
rec.insert("action".to_string(), Value::Str(e.action.clone()));
rec.insert("resource".to_string(), Value::Str(e.resource.clone()));
rec.insert("details".to_string(), Value::Str(e.details.clone()));
rec.insert(
"severity".to_string(),
Value::Str(e.severity.as_str().to_string()),
);
rec.insert("success".to_string(), Value::Bool(e.success));
Value::Record(rec)
})
.collect();
let mut result = BTreeMap::new();
result.insert("format".to_string(), Value::Str(format));
result.insert("count".to_string(), Value::Int(entries.len() as i64));
result.insert("entries".to_string(), Value::Array(entries));
Ok(Value::Record(result))
}
/// audit_stats() - Get audit log statistics
fn bi_audit_stats(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let log = AUDIT_LOG.read().unwrap();
let total = log.len();
let mut info_count = 0;
let mut warning_count = 0;
let mut critical_count = 0;
for entry in log.iter() {
match entry.severity {
AuditSeverity::Info => info_count += 1,
AuditSeverity::Warning => warning_count += 1,
AuditSeverity::Critical => critical_count += 1,
}
}
let mut result = BTreeMap::new();
result.insert("total".to_string(), Value::Int(total as i64));
result.insert("info".to_string(), Value::Int(info_count));
result.insert("warning".to_string(), Value::Int(warning_count));
result.insert("critical".to_string(), Value::Int(critical_count));
Ok(Value::Record(result))
}
/// sso_init(provider, client_id, issuer_url) - Initialize SSO configuration
fn bi_sso_init(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
if args.len() < 3 {
return Err(crate::safety::arg_err(
"sso_init: requires provider, client_id, and issuer_url arguments",
));
}
let provider_str = role_name_arg("sso_init", args.first(), "a provider name")?;
let client_id = role_name_arg("sso_init", args.get(1), "a client id")?;
let issuer_url = role_name_arg("sso_init", args.get(2), "an issuer URL")?;
let provider = SsoProvider::from_str(&provider_str).ok_or_else(|| {
crate::safety::bad_arg(
"sso_init",
"a provider of oauth2, saml or oidc",
&format!("{provider_str:?}"),
)
})?;
// The issuer is where tokens will be trusted from, so it must be an
// https address (plain http is allowed only for a loopback host).
let loopback =
issuer_url.starts_with("http://localhost") || issuer_url.starts_with("http://127.0.0.1");
if !(issuer_url.starts_with("https://") || loopback) {
return Err(crate::safety::bad_arg(
"sso_init",
"an issuer URL beginning https:// (http:// only for localhost)",
&format!("{issuer_url:?}"),
));
}
let config = SsoConfig {
provider,
client_id,
issuer_url,
enabled: true,
};
*SSO_CONFIG.write().unwrap() = Some(config);
// Tokens issued under the previous configuration are not honoured under
// the new one.
SSO_SESSIONS.write().unwrap().clear();
let mut result = BTreeMap::new();
result.insert("status".to_string(), Value::Str("initialized".to_string()));
result.insert("provider".to_string(), Value::Str(provider_str));
Ok(Value::Record(result))
}
/// sso_auth(callback_data) - Authenticate via SSO.
///
/// Not implemented. The callback is not exchanged with the identity provider
/// and its signature is not checked, so there is no identity to attest to.
/// This builtin used to accept any input, mint a token and name the user
/// `user_<length of the input>`, which made every SSO-gated check pass for
/// whoever called it.
fn bi_sso_auth(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
Err(crate::safety::unimplemented(
"sso_auth",
"does not verify the callback with the identity provider, so it cannot say who authenticated; no token is issued",
"use rbac_login with a local account for an authenticated principal",
))
}
/// sso_validate(token) - Validate an SSO token
fn bi_sso_validate(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let token = input
.or_else(|| args.first().cloned())
.map(|v| key_arg("sso_validate", v))
.transpose()?
.ok_or_else(|| crate::safety::arg_err("sso_validate: missing 'token'"))?;
let user_id = SSO_SESSIONS.read().unwrap().get(&token).cloned();
let mut result = BTreeMap::new();
result.insert("valid".to_string(), Value::Bool(user_id.is_some()));
if let Some(uid) = user_id {
result.insert("user_id".to_string(), Value::Str(uid));
}
Ok(Value::Record(result))
}
/// sso_logout(token) - Logout/invalidate an SSO session
fn bi_sso_logout(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let token = input
.or_else(|| args.first().cloned())
.map(|v| key_arg("sso_logout", v))
.transpose()?
.ok_or_else(|| crate::safety::arg_err("sso_logout: missing 'token'"))?;
let removed = SSO_SESSIONS.write().unwrap().remove(&token).is_some();
let mut result = BTreeMap::new();
result.insert(
"status".to_string(),
Value::Str(if removed { "logged_out" } else { "not_found" }.to_string()),
);
Ok(Value::Record(result))
}
/// sso_status() - Get SSO configuration status
fn bi_sso_status(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let config = SSO_CONFIG.read().unwrap();
let sessions = SSO_SESSIONS.read().unwrap();
let mut result = BTreeMap::new();
if let Some(ref c) = *config {
result.insert("configured".to_string(), Value::Bool(true));
result.insert(
"provider".to_string(),
Value::Str(c.provider.as_str().to_string()),
);
result.insert("enabled".to_string(), Value::Bool(c.enabled));
result.insert(
"active_sessions".to_string(),
Value::Int(sessions.len() as i64),
);
} else {
result.insert("configured".to_string(), Value::Bool(false));
}
Ok(Value::Record(result))
}
/// The standard named by a compliance builtin: piped in, or the first
/// argument. A standard the shell does not know is refused, not reported.
fn compliance_standard_arg(
builtin: &str,
input: &Option<Value>,
args: &[Value],
) -> Result<ComplianceStandard> {
let name = match input {
Some(v) => role_name_arg(builtin, Some(v), "a standard")?,
None => role_name_arg(builtin, args.first(), "a standard")?,
};
ComplianceStandard::from_str(&name).ok_or_else(|| {
crate::safety::bad_arg(
builtin,
"a standard of GDPR, HIPAA, SOC2 or PCI",
&format!("{name:?}"),
)
})
}
/// The controls a verdict rests on. `None` is a control the shell cannot
/// observe, which is not the same as the control being in place. Encryption
/// at rest was once asserted `true` here, so every standard was reported
/// compliant on a claim nothing checked.
fn compliance_checks() -> [(&'static str, Option<bool>); 4] {
[
("data_encryption", None),
(
"access_logging",
Some(!AUDIT_LOG.read().unwrap().is_empty()),
),
// Presence of a defined role, not evidence that roles are enforced:
// the role store is not consulted by the guard.
(
"role_based_access",
Some(!RBAC_ROLES.read().unwrap().is_empty()),
),
(
"session_management",
Some(SSO_CONFIG.read().unwrap().is_some()),
),
]
}
/// The verdict record both compliance builtins share. `compliant` is true only
/// when every control was observed and none failed.
fn compliance_verdict(standard: &ComplianceStandard) -> BTreeMap<String, Value> {
let checks = compliance_checks();
let names = |want: Option<bool>| -> Vec<Value> {
checks
.iter()
.filter(|(_, v)| *v == want)
.map(|(k, _)| Value::Str(k.to_string()))
.collect()
};
let passed = names(Some(true));
let failed = names(Some(false));
let unverified = names(None);
let compliant = failed.is_empty() && unverified.is_empty();
let mut result = BTreeMap::new();
result.insert(
"standard".to_string(),
Value::Str(standard.as_str().to_string()),
);
result.insert("passed".to_string(), Value::Array(passed));
result.insert("failed".to_string(), Value::Array(failed));
result.insert("unverified".to_string(), Value::Array(unverified));
result.insert("compliant".to_string(), Value::Bool(compliant));
result
}
/// compliance_check(standard, scope?) - Run compliance check
fn bi_compliance_check(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let standard = compliance_standard_arg("compliance_check", &input, &args)?;
// A piped standard shifts the optional scope into the first argument.
let scope_arg = if input.is_some() {
args.first()
} else {
args.get(1)
};
let scope = match scope_arg {
None => "all".to_string(),
Some(Value::Str(s)) if s.as_str() == "all" => s.clone(),
// Only the whole-system scope is checked. A subset used to be echoed
// back and then ignored, so the verdict claimed a scope it did not have.
Some(other) => {
return Err(crate::safety::bad_arg(
"compliance_check",
"a scope of \"all\", the only one implemented",
&match other {
Value::Str(s) => format!("{s:?}"),
o => o.type_name().to_string(),
},
))
}
};
let mut result = compliance_verdict(&standard);
result.insert("scope".to_string(), Value::Str(scope));
Ok(Value::Record(result))
}
/// compliance_report(standard) - Generate compliance report: the same verdict
/// as `compliance_check`, with the counts behind it.
fn bi_compliance_report(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let standard = compliance_standard_arg("compliance_report", &input, &args)?;
let mut result = compliance_verdict(&standard);
result.insert(
"generated_at".to_string(),
Value::Str(chrono::Utc::now().to_rfc3339()),
);
result.insert(
"audit_entries".to_string(),
Value::Int(AUDIT_LOG.read().unwrap().len() as i64),
);
result.insert(
"roles_defined".to_string(),
Value::Int(RBAC_ROLES.read().unwrap().len() as i64),
);
result.insert(
"sso_enabled".to_string(),
Value::Bool(SSO_CONFIG.read().unwrap().is_some()),
);
Ok(Value::Record(result))
}
/// finetune_start(model, dataset, config?) - Start a fine-tuning job
fn bi_finetune_start(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
if args.len() < 2 || args.len() > 3 {
return Err(crate::safety::bad_arg(
"finetune_start",
"model, dataset, and an optional config Record",
&format!("{} arguments", args.len()),
));
}
let model = match &args[0] {
Value::Str(s) if !s.is_empty() => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"finetune_start",
"model: a non-empty string",
other.type_name(),
))
}
};
let dataset = match &args[1] {
Value::Str(s) if !s.is_empty() => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"finetune_start",
"dataset: a non-empty string (a path or a dataset name)",
other.type_name(),
))
}
};
if let Some(other) = args.get(2) {
if !matches!(other, Value::Record(_) | Value::Null) {
return Err(crate::safety::bad_arg(
"finetune_start",
"config: a Record",
other.type_name(),
));
}
}
// No training backend is wired into this build. The job used to be recorded
// as `pending` and reported as `started`, and nothing ever advanced it: a
// job that will never run is a fabricated result. Refuse instead, so the
// caller learns that nothing was started and what would be needed.
Err(crate::safety::unimplemented(
"finetune_start",
&format!("no training job was started for model '{model}' on '{dataset}': no fine-tuning backend is wired"),
"use a provider's own fine-tuning API (for example through http_post) or an external trainer",
))
}
/// finetune_status(job_id) - Get status of a fine-tuning job
fn bi_finetune_status(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let job_id = input
.or_else(|| args.first().cloned())
.map(|v| key_arg("finetune_status", v))
.transpose()?
.ok_or_else(|| crate::safety::arg_err("finetune_status: missing 'job_id'"))?;
let jobs = FINETUNE_JOBS
.read()
.map_err(|_| crate::safety::lock_poisoned("finetune_status", "the fine-tune job table"))?;
let job = jobs
.get(&job_id)
.ok_or_else(|| crate::safety::not_found("finetune_status", "job", &job_id))?;
let mut result = BTreeMap::new();
result.insert("job_id".to_string(), Value::Str(job.id.clone()));
result.insert("model".to_string(), Value::Str(job.model.clone()));
result.insert("dataset".to_string(), Value::Str(job.dataset.clone()));
result.insert(
"status".to_string(),
Value::Str(job.status.as_str().to_string()),
);
Ok(Value::Record(result))
}
/// finetune_list() - List all fine-tuning jobs
fn bi_finetune_list(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let jobs = FINETUNE_JOBS.read().unwrap();
let result: Vec<Value> = jobs
.values()
.map(|job| {
let mut rec = BTreeMap::new();
rec.insert("job_id".to_string(), Value::Str(job.id.clone()));
rec.insert("model".to_string(), Value::Str(job.model.clone()));
rec.insert("dataset".to_string(), Value::Str(job.dataset.clone()));
rec.insert(
"status".to_string(),
Value::Str(job.status.as_str().to_string()),
);
Value::Record(rec)
})
.collect();
Ok(Value::Array(result))
}
/// finetune_cancel(job_id) - Cancel a fine-tuning job
fn bi_finetune_cancel(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let job_id = input
.or_else(|| args.first().cloned())
.map(|v| key_arg("finetune_cancel", v))
.transpose()?
.ok_or_else(|| crate::safety::arg_err("finetune_cancel: missing 'job_id'"))?;
let mut jobs = FINETUNE_JOBS
.write()
.map_err(|_| crate::safety::lock_poisoned("finetune_cancel", "the fine-tune job table"))?;
if let Some(job) = jobs.get_mut(&job_id) {
match job.status {
FinetuneStatus::Pending | FinetuneStatus::Training => {
job.status = FinetuneStatus::Cancelled;
let mut result = BTreeMap::new();
result.insert("status".to_string(), Value::Str("cancelled".to_string()));
result.insert("job_id".to_string(), Value::Str(job_id));
Ok(Value::Record(result))
}
_ => Err(crate::safety::bad_state(
"finetune_cancel",
&format!(
"job '{}' cannot be cancelled (status: {})",
job_id,
job.status.as_str()
),
"only a pending or training job can be cancelled",
)),
}
} else {
Err(crate::safety::not_found("finetune_cancel", "job", &job_id))
}
}
// ===================== A2UI (Agent-to-User Interface) Builtins =====================
/// Refuse an interactive prompt when no UI has ever attached to the channel.
///
/// Checked before the prompt is queued, so no dangling prompt event is left for
/// a UI that does not exist. The channel's own check repeats this, but it names
/// `a2ui`, not the builtin the agent called.
fn require_a2ui_listener(builtin: &str) -> Result<()> {
if A2UI_CHANNEL.ui_attached() {
Ok(())
} else {
Err(crate::safety::no_ui(builtin))
}
}
/// The message-or-subject and the parameters that follow it.
///
/// Piped, the subject is the input and every argument is a parameter. Called
/// directly, the first argument is the subject, as `validate` assumes.
fn a2ui_subject_and_rest<'a>(
args: &'a [Value],
input: &Option<Value>,
) -> (Option<Value>, &'a [Value]) {
match input {
Some(v) => (Some(v.clone()), args),
None => (args.first().cloned(), args.get(1..).unwrap_or(&[])),
}
}
/// a2ui_notify(message, {level: "info"|"success"|"warning"|"error"}) - Send notification to user
fn bi_a2ui_notify(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let message = input
.or_else(|| args.first().cloned())
.map(|v| v.to_display_string())
.ok_or_else(|| crate::safety::arg_err("a2ui_notify: missing message"))?;
let level = args
.get(1)
.and_then(|v| {
if let Value::Record(r) = v {
r.get("level").map(|l| l.to_display_string())
} else if let Value::Str(s) = v {
Some(s.clone())
} else {
None
}
})
.unwrap_or_else(|| "info".to_string())
.parse::<NotificationLevel>()
// An unknown level ("critical") was shown as info, silently.
.map_err(|_| {
crate::safety::bad_arg(
"a2ui_notify",
"level info, success, warning or error",
"another level",
)
})?;
A2UI_CHANNEL.notify_level("builtin", &message, level)?;
Ok(Value::Record({
let mut r = BTreeMap::new();
r.insert("sent".to_string(), Value::Bool(true));
r.insert(
"ui_attached".to_string(),
Value::Bool(A2UI_CHANNEL.ui_attached()),
);
r.insert("message".to_string(), Value::Str(message));
r
}))
}
/// a2ui_prompt(message) - Request text input from user (blocking)
///
/// Refused with `E_NO_UI` when no UI is attached. A user who cancels gets null.
/// Dispatch never reaches a reserved slot, since no name is registered at its
/// index; the entry only keeps the numbering of the ones after it.
fn reserved_slot() -> anyhow::Error {
crate::safety::unimplemented(
"reserved builtin slot",
"no builtin is registered at this index",
"call a registered builtin",
)
}
/// The attached UI answered a prompt with a different kind of response, which
/// is a fault in the UI, not in the call: `E_IO`, naming what came back.
fn a2ui_wrong_answer(builtin: &str, asked: &str, got: &PromptResponse) -> anyhow::Error {
let kind = match got {
PromptResponse::Text(_) => "text",
PromptResponse::Confirm(_) => "confirm",
PromptResponse::Select(_) => "select",
PromptResponse::MultiSelect(_) => "multi-select",
PromptResponse::Cancelled => "cancelled",
};
crate::safety::io_fault(
builtin,
&format!("the attached UI answered a {asked} prompt with a {kind} response"),
"check the UI client attached to the A2UI channel, then retry",
)
}
#[cfg(test)]
mod coded_fault_tests {
use super::*;
use crate::safety::{ErrorCode, SafetyError};
fn code(e: &anyhow::Error) -> ErrorCode {
e.downcast_ref::<SafetyError>().expect("coded").code
}
#[test]
fn reserved_slots_and_ui_protocol_faults_carry_codes() {
assert_eq!(code(&reserved_slot()), ErrorCode::Unimplemented);
let e = a2ui_wrong_answer("a2ui_prompt", "text", &PromptResponse::Confirm(true));
assert_eq!(code(&e), ErrorCode::Io);
assert!(e.to_string().contains("a confirm response"), "{e}");
}
}
fn bi_a2ui_prompt(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let (subject, _) = a2ui_subject_and_rest(&args, &input);
let message = subject
.map(|v| v.to_display_string())
.ok_or_else(|| crate::safety::arg_err("a2ui_prompt: missing message"))?;
require_a2ui_listener("a2ui_prompt")?;
match A2UI_CHANNEL.prompt_text("builtin", &message)? {
PromptResponse::Text(s) => Ok(Value::Str(s)),
PromptResponse::Cancelled => Ok(Value::Null),
other => Err(a2ui_wrong_answer("a2ui_prompt", "text", &other)),
}
}
/// a2ui_confirm(message) - Request yes/no confirmation from user (blocking)
///
/// Refused with `E_NO_UI` when no UI is attached. It never answers `false` for
/// "nobody is there": that is a decision, and nobody made it.
fn bi_a2ui_confirm(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let (subject, _) = a2ui_subject_and_rest(&args, &input);
let message = subject
.map(|v| v.to_display_string())
.ok_or_else(|| crate::safety::arg_err("a2ui_confirm: missing message"))?;
require_a2ui_listener("a2ui_confirm")?;
let result = A2UI_CHANNEL.prompt_confirm("builtin", &message)?;
Ok(Value::Bool(result))
}
/// a2ui_select(message, options) - Request selection from user (blocking)
///
/// Returns the chosen option, or null when the user cancels.
fn bi_a2ui_select(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let (subject, rest) = a2ui_subject_and_rest(&args, &input);
let message = subject
.map(|v| v.to_display_string())
.ok_or_else(|| crate::safety::arg_err("a2ui_select: missing message"))?;
let options: Vec<String> = match rest.first() {
Some(Value::Array(arr)) if !arr.is_empty() => {
arr.iter().map(|x| x.to_display_string()).collect()
}
Some(Value::Array(_)) => {
return Err(crate::safety::bad_arg(
"a2ui_select",
"options: a non-empty Array",
"an empty Array",
))
}
Some(other) => {
return Err(crate::safety::bad_arg(
"a2ui_select",
"options: Array",
other.type_name(),
))
}
None => {
return Err(crate::safety::bad_arg(
"a2ui_select",
"options: Array",
"nothing",
))
}
};
require_a2ui_listener("a2ui_select")?;
match A2UI_CHANNEL.prompt_select("builtin", &message, options.clone())? {
PromptResponse::Select(idx) => match options.get(idx) {
Some(chosen) => Ok(Value::Str(chosen.clone())),
// An index past the end is the UI's answer being wrong. Null read as
// "cancelled", which it was not.
None => Err(crate::safety::bad_arg(
"a2ui_select",
"an index into the options",
&format!("index {idx} of {}", options.len()),
)),
},
PromptResponse::Cancelled => Ok(Value::Null),
other => Err(a2ui_wrong_answer("a2ui_select", "select", &other)),
}
}
/// a2ui_progress(label, current, total, progress_id?) - Update a progress indicator
///
/// Queued for the UI like the other display calls; it does not wait for one.
fn bi_a2ui_progress(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("a2ui_progress", &input)?;
let label = args
.first()
.map(|v| v.to_display_string())
.ok_or_else(|| crate::safety::arg_err("a2ui_progress: missing label"))?;
// Counts are whole and non-negative: a negative or fractional count used to
// be cast to an unsigned number, silently.
let count = |i: usize, what: &str| -> Result<u64> {
match args.get(i) {
Some(Value::Int(n)) if *n >= 0 => Ok(*n as u64),
Some(other) => Err(crate::safety::bad_arg(
"a2ui_progress",
&format!("{what}: a whole number, 0 or more"),
other.type_name(),
)),
None => Err(crate::safety::arg_err(format!(
"a2ui_progress: missing {what} value"
))),
}
};
let current = count(1, "current")?;
let total = count(2, "total")?;
if current > total {
return Err(crate::safety::bad_arg(
"a2ui_progress",
"current: at most total",
&format!("{current} of {total}"),
));
}
// Use the given progress ID, or make one. A given ID that is not a UUID was
// replaced by a new one without a word.
let progress_id = match args.get(3) {
None => uuid::Uuid::new_v4(),
Some(Value::Str(s)) => uuid::Uuid::parse_str(s)
.map_err(|_| crate::safety::bad_arg("a2ui_progress", "progress_id: a UUID", s))?,
Some(other) => {
return Err(crate::safety::bad_arg(
"a2ui_progress",
"progress_id: a UUID string",
other.type_name(),
))
}
};
A2UI_CHANNEL.progress("builtin", progress_id, &label, current, total)?;
Ok(Value::Record({
let mut r = BTreeMap::new();
r.insert(
"progress_id".to_string(),
Value::Str(progress_id.to_string()),
);
r.insert("current".to_string(), Value::Int(current as i64));
r.insert("total".to_string(), Value::Int(total as i64));
r
}))
}
/// a2ui_progress_complete(progress_id) - Complete a progress indicator
fn bi_a2ui_progress_complete(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let id_str = input
.or_else(|| args.first().cloned())
.map(|v| v.to_display_string())
.ok_or_else(|| crate::safety::arg_err("a2ui_progress_complete: missing progress_id"))?;
let progress_id = uuid::Uuid::parse_str(&id_str)
.map_err(|_| crate::safety::arg_err("a2ui_progress_complete: invalid progress_id"))?;
A2UI_CHANNEL.progress_complete("builtin", progress_id)?;
Ok(Value::Bool(true))
}
/// a2ui_render(content) - Render content in the UI
fn bi_a2ui_render(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let content = input
.or_else(|| args.first().cloned())
.ok_or_else(|| crate::safety::arg_err("a2ui_render: missing content"))?;
let render_content = match &content {
Value::Str(s) => RenderContent::Text(s.clone()),
Value::Record(r) => {
// Check for specific render types
if let Some(Value::Str(md)) = r.get("markdown") {
RenderContent::Markdown(md.clone())
} else if let Some(Value::Str(json_str)) = r.get("json") {
// Invalid JSON rendered as null, silently.
RenderContent::Json(serde_json::from_str(json_str).map_err(|e| {
crate::safety::bad_arg(
"a2ui_render",
"valid JSON in the json field",
&e.to_string(),
)
})?)
} else {
RenderContent::Json(serde_json::to_value(&content).unwrap_or_default())
}
}
Value::Array(_) => RenderContent::Json(serde_json::to_value(&content).unwrap_or_default()),
_ => RenderContent::Text(content.to_display_string()),
};
A2UI_CHANNEL.render("builtin", render_content)?;
Ok(Value::Bool(true))
}
/// a2ui_render_table(headers, rows) - Render a table in the UI
fn bi_a2ui_render_table(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let headers: Vec<String> = match args.first() {
Some(Value::Array(arr)) => arr.iter().map(|x| x.to_display_string()).collect(),
_ => {
return Err(crate::safety::bad_arg(
"a2ui_render_table",
"headers: Array",
args.first().map_or("nothing", |v| v.type_name()),
))
}
};
// Rows come from the second argument, or from the pipe. A row that is
// neither an array nor a record used to be filtered out without a word.
let rows_value = args.get(1).or(input.as_ref());
let Some(Value::Array(arr)) = rows_value else {
return Err(crate::safety::bad_arg(
"a2ui_render_table",
"rows: Array",
rows_value.map_or("nothing", |v| v.type_name()),
));
};
let mut rows: Vec<Vec<String>> = Vec::with_capacity(arr.len());
for row in arr {
match row {
Value::Array(row_arr) => {
rows.push(row_arr.iter().map(|x| x.to_display_string()).collect())
}
Value::Record(row_rec) => rows.push(
headers
.iter()
.map(|h| {
row_rec
.get(h)
.map(|v| v.to_display_string())
.unwrap_or_default()
})
.collect(),
),
other => {
return Err(crate::safety::bad_arg(
"a2ui_render_table",
"each row: an Array or a Record",
other.type_name(),
))
}
}
}
A2UI_CHANNEL.render("builtin", RenderContent::Table { headers, rows })?;
Ok(Value::Bool(true))
}
/// a2ui_render_code(language, code) - Render code with syntax highlighting
fn bi_a2ui_render_code(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let language = args
.first()
.map(|v| v.to_display_string())
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| crate::safety::arg_err("a2ui_render_code: missing language"))?;
let code = args
.get(1)
.or(input.as_ref())
.map(|v| v.to_display_string())
.ok_or_else(|| crate::safety::arg_err("a2ui_render_code: missing code"))?;
A2UI_CHANNEL.render(
"builtin",
RenderContent::Code {
language,
content: code,
},
)?;
Ok(Value::Bool(true))
}
/// a2ui_toast(message, level, duration_ms) - Show a toast notification
fn bi_a2ui_toast(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let message = input
.or_else(|| args.first().cloned())
.map(|v| v.to_display_string())
.ok_or_else(|| crate::safety::arg_err("a2ui_toast: missing message"))?;
let level = args
.get(1)
.map(|v| v.to_display_string())
.unwrap_or_else(|| "info".to_string())
.parse::<NotificationLevel>()
// An unknown level ("critical") was shown as info, silently.
.map_err(|_| {
crate::safety::bad_arg(
"a2ui_toast",
"level info, success, warning or error",
"another level",
)
})?;
let duration_ms = args
.get(2)
.and_then(|v| match v {
Value::Int(n) => Some(*n as u64),
Value::Float(f) => Some(*f as u64),
_ => None,
})
.unwrap_or(3000);
A2UI_CHANNEL.toast("builtin", &message, level, duration_ms)?;
Ok(Value::Bool(true))
}
/// a2ui_status(text) - Update the status bar
fn bi_a2ui_status(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let text = input
.or_else(|| args.first().cloned())
.map(|v| v.to_display_string())
.ok_or_else(|| crate::safety::arg_err("a2ui_status: missing text"))?;
A2UI_CHANNEL.status("builtin", &text)?;
Ok(Value::Bool(true))
}
/// a2ui_clear() - Clear the A2UI event channel
fn bi_a2ui_clear(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
A2UI_CHANNEL.clear()?;
Ok(Value::Bool(true))
}
/// a2ui_agent_started(agent_id, task) - Notify that an agent started working
fn bi_a2ui_agent_started(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let agent_id = input
.or_else(|| args.first().cloned())
.map(|v| v.to_display_string())
.ok_or_else(|| crate::safety::arg_err("a2ui_agent_started: missing agent_id"))?;
let task = args.get(1).map(|v| v.to_display_string());
A2UI_CHANNEL.send(A2UIEvent::new(
"builtin",
A2UIEventType::AgentStarted { agent_id, task },
))?;
Ok(Value::Bool(true))
}
/// a2ui_agent_completed(agent_id, success, result) - Notify that an agent completed
fn bi_a2ui_agent_completed(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let agent_id = input
.or_else(|| args.first().cloned())
.map(|v| v.to_display_string())
.ok_or_else(|| crate::safety::arg_err("a2ui_agent_completed: missing agent_id"))?;
// An absent flag means success; a supplied flag must be a Bool. A string
// such as "false" or a 0 is refused, not read as success.
let success = match args.get(1) {
None => true,
Some(Value::Bool(b)) => *b,
Some(other) => {
return Err(crate::safety::bad_arg(
"a2ui_agent_completed",
"a success flag that is a Bool (true or false), or no flag",
other.type_name(),
))
}
};
let result = args.get(2).map(|v| v.to_display_string());
A2UI_CHANNEL.send(A2UIEvent::new(
"builtin",
A2UIEventType::AgentCompleted {
agent_id,
result,
success,
},
))?;
Ok(Value::Bool(true))
}
/// a2ui_agent_thinking(agent_id, thought, step) - Show agent's reasoning process
fn bi_a2ui_agent_thinking(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let agent_id = args
.first()
.map(|v| v.to_display_string())
.ok_or_else(|| crate::safety::arg_err("a2ui_agent_thinking: missing agent_id"))?;
let thought = args
.get(1)
.or(input.as_ref())
.map(|v| v.to_display_string())
.ok_or_else(|| crate::safety::arg_err("a2ui_agent_thinking: missing thought"))?;
let step = args
.get(2)
.and_then(|v| match v {
Value::Int(n) => Some(*n as usize),
_ => None,
})
.unwrap_or(0);
A2UI_CHANNEL.send(A2UIEvent::new(
"builtin",
A2UIEventType::AgentThinking {
agent_id,
thought,
step,
},
))?;
Ok(Value::Bool(true))
}
// ============================================================================
// 230-249: Process Management
// ============================================================================
/// Refuses pipeline input and more arguments than a builtin takes.
///
/// These builtins ignored a piped subject, and `proc_list(1, 2)` answered as
/// if no arguments had been given.
fn proc_check(builtin: &str, args: &[Value], input: &Option<Value>, max_args: usize) -> Result<()> {
if input.is_some() {
return Err(crate::safety::bad_arg(
builtin,
"no pipeline input",
"pipeline input",
));
}
if args.len() > max_args {
return Err(crate::safety::bad_arg(
builtin,
&format!("at most {max_args} argument(s)"),
&args.len().to_string(),
));
}
Ok(())
}
/// A process's `sysinfo` record, refreshed for that one pid. The pid must
/// already have been checked to fit `u32` (see `proc_pid`).
fn proc_sys(pid: i64) -> System {
let spid = sysinfo::Pid::from_u32(pid as u32);
let mut sys = System::new();
sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[spid]), true);
sys
}
/// Whether a process with this pid is live now.
fn proc_live(pid: i64) -> bool {
proc_sys(pid)
.process(sysinfo::Pid::from_u32(pid as u32))
.is_some()
}
/// E_NOT_FOUND unless the process is live. Every reader asks this first, so
/// a pid that does not exist is one coded error, never a null, a zero, an
/// empty record or an empty array.
fn proc_require(builtin: &str, pid: i64) -> Result<()> {
if proc_live(pid) {
Ok(())
} else {
Err(crate::safety::not_found(
builtin,
"process",
&pid.to_string(),
))
}
}
fn proc_is_self(pid: i64) -> bool {
pid == i64::from(std::process::id())
}
/// Refuses this shell's own pid. Stopping it would stop the caller too.
fn proc_refuse_self(builtin: &str, pid: i64) -> Result<()> {
if proc_is_self(pid) {
return Err(crate::safety::bad_arg(
builtin,
"a process other than this shell",
&format!("its own pid {pid}"),
));
}
Ok(())
}
/// A pid for a builtin that reads or acts on one process: a positive integer,
/// or its spelling, that the operating system's pid type can hold.
///
/// `pid_arg` admits any positive i64, and the sysinfo lookup casts it to
/// `u32`, which would turn 4294967297 into pid 1.
fn proc_pid(builtin: &str, v: Option<&Value>) -> Result<i64> {
let pid = pid_arg(builtin, v)?;
#[cfg(unix)]
let fits = libc::pid_t::try_from(pid).is_ok();
#[cfg(not(unix))]
let fits = u32::try_from(pid).is_ok();
if !fits {
return Err(crate::safety::bad_arg(
builtin,
"a process id that fits the operating system's pid type",
&pid.to_string(),
));
}
Ok(pid)
}
/// Runs a host tool for a reader. A tool that cannot start is E_TOOL_MISSING;
/// one that runs and fails is E_TOOL_FAILED with its stderr.
fn proc_tool(
builtin: &str,
tool: &str,
cmd: std::process::Command,
deadline_secs: u64,
) -> Result<std::process::Output> {
let out = bounded_cmd_output(builtin, tool, cmd, deadline_secs)?;
if out.status.success() {
return Ok(out);
}
Err(crate::safety::tool_failed(
builtin,
tool,
out.status.code(),
&String::from_utf8_lossy(&out.stderr),
))
}
/// Reads one value a host tool printed. Text that is not the value is a
/// coded failure, never a zero.
fn proc_parse<T: std::str::FromStr>(builtin: &str, tool: &str, text: &str) -> Result<T> {
text.trim().parse::<T>().map_err(|_| {
crate::safety::tool_failed(
builtin,
tool,
Some(0),
&format!(
"its output was not a value this builtin reads: {:?}",
text.trim()
),
)
})
}
/// A refused priority change or signal: a permission refusal is E_POLICY_DENY,
/// anything else E_TOOL_FAILED with the tool's own words.
fn proc_refused(
builtin: &str,
tool: &str,
pid: i64,
stderr: &[u8],
status: Option<i32>,
) -> anyhow::Error {
let said = String::from_utf8_lossy(stderr).trim().to_string();
let lower = said.to_ascii_lowercase();
if [
"permission",
"not permitted",
"access is denied",
"access denied",
]
.iter()
.any(|w| lower.contains(w))
{
return crate::safety::os_denied(
builtin,
format!("{builtin}: the operating system refused to change process {pid}: {said}"),
"the process belongs to another user, or this change needs elevated rights; change a process this user owns",
);
}
crate::safety::tool_failed(builtin, tool, status, &said)
}
/// A /proc entry that could not be read, coded by the reason: a missing entry
/// is E_NOT_FOUND and a refused one E_POLICY_DENY.
#[cfg(target_os = "linux")]
fn proc_read_failed(builtin: &str, pid: i64, what: &str, e: &std::io::Error) -> anyhow::Error {
match e.kind() {
std::io::ErrorKind::NotFound => {
crate::safety::not_found(builtin, "process", &pid.to_string())
}
std::io::ErrorKind::PermissionDenied => crate::safety::os_denied(
builtin,
format!("{builtin}: the operating system denied reading {what} of process {pid}"),
"the process belongs to another user; read processes this user owns",
),
_ => crate::safety::tool_failed(
builtin,
&format!("reading {what} of process {pid}"),
Some(0),
&e.to_string(),
),
}
}
/// A Windows process image name without its ".exe" suffix. The suffix is
/// matched without regard to case, so the name reads as Get-Process's
/// ProcessName did.
#[cfg(windows)]
fn proc_strip_exe(name: &str) -> &str {
match name.len().checked_sub(4) {
Some(cut) if name.is_char_boundary(cut) && name[cut..].eq_ignore_ascii_case(".exe") => {
&name[..cut]
}
_ => name,
}
}
/// Total CPU seconds (kernel plus user time) of process `pid`, the value
/// Get-Process's CPU column reported. None when the process cannot be opened
/// or queried, which the caller turns into Null as PowerShell did.
#[cfg(windows)]
fn win_process_cpu_seconds(pid: u32) -> Option<f64> {
use std::ffi::c_void;
#[link(name = "kernel32")]
extern "system" {
fn GetProcessTimes(
process: *mut c_void,
creation: *mut WinFileTime,
exit: *mut WinFileTime,
kernel: *mut WinFileTime,
user: *mut WinFileTime,
) -> i32;
}
win_with_process("proc_list", i64::from(pid), |h| {
let mut creation = WinFileTime::default();
let mut exit = WinFileTime::default();
let mut kernel = WinFileTime::default();
let mut user = WinFileTime::default();
// SAFETY: `h` is the live handle win_with_process opened; each
// out-pointer is a valid, writable FILETIME.
if unsafe { GetProcessTimes(h, &mut creation, &mut exit, &mut kernel, &mut user) } == 0 {
return Err(crate::safety::bad_state(
"proc_list",
&format!(
"GetProcessTimes failed: {}",
std::io::Error::last_os_error()
),
"a live process this user can query",
));
}
Ok((kernel.ticks() + user.ticks()) as f64 / 1e7)
})
.ok()
}
fn bi_proc_list(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
proc_check("proc_list", &args, &input, 0)?;
// Was `Get-Process | Select-Object Id,ProcessName,CPU,WorkingSet64` run
// through PowerShell and parsed from its JSON; now read in process. sysinfo
// lists the processes (image name, working set) and GetProcessTimes gives
// the CPU seconds. Rows are sorted by pid.
#[cfg(windows)]
let procs = {
let mut sys = System::new();
sys.refresh_processes_specifics(
sysinfo::ProcessesToUpdate::All,
true,
sysinfo::ProcessRefreshKind::nothing().with_memory(),
);
let mut rows: Vec<(u32, &sysinfo::Process)> = sys
.processes()
.iter()
.map(|(pid, process)| (pid.as_u32(), process))
.collect();
rows.sort_unstable_by_key(|(pid, _)| *pid);
let mut procs = Vec::new();
for (pid, process) in rows {
let raw = process.name().to_string_lossy().into_owned();
let name = proc_strip_exe(&raw).to_string();
let mut rec = BTreeMap::new();
rec.insert("pid".to_string(), Value::Int(i64::from(pid)));
rec.insert("name".to_string(), Value::Str(name.clone()));
rec.insert(
"cpu".to_string(),
win_process_cpu_seconds(pid)
.map(Value::Float)
.unwrap_or(Value::Null),
);
rec.insert("mem".to_string(), Value::Int(process.memory() as i64));
rec.insert("command".to_string(), Value::Str(name));
procs.push(Value::Record(rec));
}
procs
};
#[cfg(not(windows))]
let procs = {
let out = proc_tool(
"proc_list",
"ps",
{
let mut c = crate::safety::child_command("ps");
c.arg("aux");
c
},
PROBE_DEADLINE_SECS,
)?;
let mut procs = Vec::new();
for line in String::from_utf8_lossy(&out.stdout).lines().skip(1) {
if line.trim().is_empty() {
continue;
}
// USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND...
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 11 {
return Err(crate::safety::tool_failed(
"proc_list",
"ps",
Some(0),
&format!("a process line had {} fields, not 11 or more", parts.len()),
));
}
let pid: i64 = proc_parse("proc_list", "ps", parts[1])?;
let cpu: f64 = proc_parse("proc_list", "ps", parts[2])?;
let rss_kb: i64 = proc_parse("proc_list", "ps", parts[5])?;
let command = parts[10..].join(" ");
let mut rec = BTreeMap::new();
rec.insert("pid".to_string(), Value::Int(pid));
rec.insert("name".to_string(), Value::Str(command.clone()));
rec.insert("cpu".to_string(), Value::Float(cpu));
rec.insert("mem".to_string(), Value::Int(rss_kb.saturating_mul(1024)));
rec.insert("command".to_string(), Value::Str(command));
procs.push(Value::Record(rec));
}
procs
};
Ok(Value::Array(procs))
}
/// A process id: a positive integer, or a string of one.
///
/// Four builtins parsed a string with `unwrap_or(0)`, so a process *name*
/// became pid 0, and none refused zero or a negative pid. To `kill`, 0 means
/// the caller's own process group and -1 means every process the user may
/// signal: `proc_kill("firefox")` signalled the shell's own group, and
/// `proc_kill(-1)` everything.
fn pid_arg(builtin: &str, v: Option<&Value>) -> Result<i64> {
let pid = match v {
Some(Value::Int(p)) => Some(*p),
Some(Value::Str(s)) => s.trim().parse::<i64>().ok(),
_ => None,
};
match pid {
// Above u32::MAX a pid is no process; `as u32` would wrap it onto one.
Some(p) if p > 0 && p <= i64::from(u32::MAX) => Ok(p),
_ => Err(crate::safety::bad_arg(
builtin,
"a process id: a positive integer (0 and negatives address groups of processes)",
&v.map(|v| v.to_display_string())
.unwrap_or_else(|| "nothing".into()),
)),
}
}
/// A coded `E_TOOL_FAILED` for a signal the OS or `taskkill` refused.
///
/// `tool_failed` describes an exit status, which a signal has no equivalent
/// of, so this states the refusal in its own words.
fn proc_kill_failed(message: String, hint: &str, got: &str) -> anyhow::Error {
anyhow::Error::new(crate::safety::SafetyError {
code: crate::safety::ErrorCode::ToolFailed,
message,
builtin: "proc_kill".to_string(),
hint: hint.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: "the signal to be delivered".to_string(),
got: got.to_string(),
})
}
/// The Record a successful `proc_kill` answers. A tree kill also names every
/// process it signalled, in the order it signalled them, as `killed`.
fn proc_kill_sent(pid: i64, signal: &str, killed: Option<&[i64]>) -> Value {
let mut rec = BTreeMap::new();
rec.insert("pid".to_string(), Value::Int(pid));
rec.insert("signal".to_string(), Value::Str(signal.to_string()));
rec.insert("sent".to_string(), Value::Bool(true));
if let Some(killed) = killed {
rec.insert(
"killed".to_string(),
Value::Array(killed.iter().map(|p| Value::Int(*p)).collect()),
);
}
Value::Record(rec)
}
/// The processes under `root`, breadth-first, as sysinfo reports them now. It
/// is read before anything is signalled. A child that started before its
/// parent is a reused pid and not a child, so it is left out. More than 4096
/// descendants is E_BUDGET_EXCEEDED, raised before anything is signalled.
///
/// The per-pid checks (a pid that fits, this shell's own pid) apply to each
/// descendant at the caller. A descendant's pid is a u32 from sysinfo, so it
/// always fits the OS pid type.
fn proc_kill_descendants(root: i64) -> Result<Vec<i64>> {
const LIMIT: usize = 4096;
let mut sys = System::new();
sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
let mut children: std::collections::HashMap<u32, Vec<u32>> = std::collections::HashMap::new();
for (pid, proc_) in sys.processes() {
let Some(parent) = proc_.parent() else {
continue;
};
let Some(parent_proc) = sys.process(parent) else {
continue;
};
if proc_.start_time() < parent_proc.start_time() {
continue;
}
children
.entry(parent.as_u32())
.or_default()
.push(pid.as_u32());
}
for kids in children.values_mut() {
kids.sort_unstable();
}
let root = u32::try_from(root).unwrap_or(u32::MAX);
let mut seen = std::collections::HashSet::from([root]);
let mut queue = std::collections::VecDeque::from([root]);
let mut found: Vec<i64> = Vec::new();
while let Some(parent) = queue.pop_front() {
let Some(kids) = children.get(&parent) else {
continue;
};
for kid in kids {
if !seen.insert(*kid) {
continue;
}
if found.len() == LIMIT {
return Err(crate::safety::budget_exceeded(
"proc_kill",
format!(
"proc_kill: process {root} has more than {LIMIT} descendants; NOTHING WAS SENT"
),
"signal a smaller subtree, or signal its children separately",
));
}
found.push(i64::from(*kid));
queue.push_back(*kid);
}
}
Ok(found)
}
/// What a failed Unix tree kill already signalled, for the error message.
#[cfg(unix)]
fn proc_kill_partial(killed: &[i64], signal: &str) -> String {
if killed.is_empty() {
String::new()
} else {
format!(
"; {} earlier process(es) of the tree were sent {signal} before this failed",
killed.len()
)
}
}
fn bi_proc_kill(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
// A piped subject would be silently ignored; refuse it before anything runs.
if input.is_some() {
return Err(crate::safety::bad_arg(
"proc_kill",
"no pipeline input",
"pipeline input",
));
}
let pid = pid_arg("proc_kill", args.first())?;
// Parsed once, before anything runs, on every OS. A numeric signal was
// ignored (only strings were read) and sent as TERM, and an unknown name
// ("SIGKILL", "USR1") became TERM too. On Windows every name did.
let signal = match args.get(1) {
None => "TERM".to_string(),
Some(Value::Str(s)) => s.trim().trim_start_matches("SIG").to_ascii_uppercase(),
Some(Value::Int(n)) => n.to_string(),
Some(other) => {
return Err(crate::safety::bad_arg(
"proc_kill",
"signal: a name (TERM, KILL, ...) or number",
other.type_name(),
))
}
};
let (name, num) = match signal.as_str() {
"TERM" | "15" => ("TERM", "-15"),
"KILL" | "9" => ("KILL", "-9"),
"HUP" | "1" => ("HUP", "-1"),
"INT" | "2" => ("INT", "-2"),
"QUIT" | "3" => ("QUIT", "-3"),
"STOP" | "19" => ("STOP", "-19"),
"CONT" | "18" => ("CONT", "-18"),
other => {
return Err(crate::safety::bad_arg(
"proc_kill",
"signal TERM, KILL, HUP, INT, QUIT, STOP or CONT (or its number)",
other,
))
}
};
let signal = name;
// `num` is the kill(1) spelling; neither OS path below uses it any more.
let _ = num;
// The options Record, third and last. `tree` is its only key, and it is
// read here, before any effect, so a refusal signals nothing.
let tree = match args.get(2) {
None => false,
Some(Value::Record(rec)) => {
cfg_known_keys("proc_kill", rec, &["tree"])?;
cfg_bool("proc_kill", rec, "tree")?.unwrap_or(false)
}
Some(other) => {
return Err(crate::safety::bad_arg(
"proc_kill",
"options: a Record {tree: Bool}",
other.type_name(),
))
}
};
// Refused before the guard: these pids address groups of processes or this
// shell itself, and a pid that does not fit the OS's pid type is no process.
#[cfg(unix)]
let fits = libc::pid_t::try_from(pid).is_ok();
#[cfg(not(unix))]
let fits = u32::try_from(pid).is_ok();
if !fits {
return Err(crate::safety::bad_arg(
"proc_kill",
"a process id that fits the operating system's pid type",
&pid.to_string(),
));
}
let own = i64::from(std::process::id());
if pid == own {
return Err(crate::safety::bad_arg(
"proc_kill",
"a process other than this shell",
&format!("its own pid {pid}"),
));
}
// A tree kill reads the descendants now, before any signal, so that each
// can be checked and the guard sees the whole set. If this shell is among
// them (the target is one of its ancestors), the whole call is refused.
let descendants = if tree {
proc_kill_descendants(pid)?
} else {
Vec::new()
};
if let Some(d) = descendants.iter().find(|d| **d == own) {
return Err(crate::safety::bad_arg(
"proc_kill",
"a process other than this shell, in the tree",
&format!("its own pid {d}, a descendant of {pid}; NOTHING WAS SENT"),
));
}
let mut targets = vec![pid.to_string()];
targets.extend(descendants.iter().map(|d| d.to_string()));
let blast_radius = if tree {
serde_json::json!({
"pid": pid,
"signal": signal,
"tree": true,
"descendants": descendants.len(),
})
} else {
serde_json::json!({ "pid": pid, "signal": signal })
};
crate::safety::guard(crate::safety::GuardCtx {
builtin: "proc_kill",
effect: crate::safety::Effect::Process,
what: "kill",
targets,
blast_radius,
reversible: false,
fs_paths: false,
})?;
// Deepest first, then the target, as the Unix path signals them. Windows
// has taskkill walk the tree, so this is the order it is reported in.
#[cfg(windows)]
let order: Vec<i64> = descendants
.iter()
.rev()
.copied()
.chain(std::iter::once(pid))
.collect();
#[cfg(windows)]
{
// taskkill has a graceful close and a forced one; nothing else maps.
let force = match signal {
"TERM" => false,
"KILL" => true,
other => {
return Err(crate::safety::unimplemented(
"proc_kill",
&format!("signal {other} has no Windows equivalent; NOTHING WAS SENT"),
"use TERM (graceful) or KILL (forced)",
))
}
};
let mut cmd = crate::safety::child_command("taskkill");
cmd.args(["/PID", &pid.to_string()]);
if tree {
cmd.arg("/T");
}
if force {
cmd.arg("/F");
}
let output = bounded_cmd_output("proc_kill", "taskkill", cmd, TOOL_DEADLINE_SECS)?;
if output.status.success() {
return Ok(proc_kill_sent(
pid,
signal,
tree.then_some(order.as_slice()),
));
}
let said = format!(
"{} {}",
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout)
);
// taskkill's exit 128 is "no such process"; the lookup confirms it
// where the code is not stable across Windows releases.
let mut sys = System::new();
let spid = sysinfo::Pid::from_u32(pid as u32);
sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[spid]), true);
if output.status.code() == Some(128) || sys.process(spid).is_none() {
return Err(crate::safety::not_found(
"proc_kill",
"process",
&pid.to_string(),
));
}
if said.contains("forcefully") {
return Err(proc_kill_failed(
format!(
"proc_kill: process {pid} is a console process and cannot take {signal}; NOTHING WAS SENT"
),
"a console process only ends when forced: call proc_kill with signal KILL",
"a process that refuses a graceful close",
));
}
Err(crate::safety::tool_failed(
"proc_kill",
&format!("taskkill /PID {pid}"),
output.status.code(),
&said,
))
}
#[cfg(unix)]
{
let sig = match signal {
"TERM" => libc::SIGTERM,
"KILL" => libc::SIGKILL,
"HUP" => libc::SIGHUP,
"INT" => libc::SIGINT,
"QUIT" => libc::SIGQUIT,
"STOP" => libc::SIGSTOP,
"CONT" => libc::SIGCONT,
other => {
return Err(crate::safety::bad_arg(
"proc_kill",
"signal TERM, KILL, HUP, INT, QUIT, STOP or CONT (or its number)",
other,
))
}
};
// The fit was checked above. kill(2) reads only its two arguments.
let native_of = |p: i64| libc::pid_t::try_from(p).unwrap_or(libc::pid_t::MAX);
let mut killed: Vec<i64> = Vec::new();
// Descendants first, deepest first, so the target is still alive only
// until its children are gone and cannot respawn one. A descendant that
// has already exited (ESRCH) is not an error.
for &child in descendants.iter().rev() {
// SAFETY: kill(2) takes a pid and a signal number and touches no memory.
if unsafe { libc::kill(native_of(child), sig) } != 0 {
let err = std::io::Error::last_os_error();
let partial = proc_kill_partial(&killed, signal);
match err.raw_os_error() {
Some(libc::ESRCH) => continue,
Some(libc::EPERM) => {
return Err(crate::safety::os_denied(
"proc_kill",
format!(
"proc_kill: the operating system denied {signal} to process {child}, a descendant of {pid} (EPERM){partial}"
),
"the process belongs to another user or is protected; signal a process this user owns",
))
}
_ => {
return Err(proc_kill_failed(
format!("proc_kill: kill({child}, {signal}) failed: {err}{partial}"),
"the OS refused the signal; the message names the errno",
&err.to_string(),
))
}
}
}
killed.push(child);
}
// SAFETY: kill(2) takes a pid and a signal number and touches no memory.
if unsafe { libc::kill(native_of(pid), sig) } != 0 {
let err = std::io::Error::last_os_error();
let partial = proc_kill_partial(&killed, signal);
match err.raw_os_error() {
// The tree's members were signalled and the root has since
// gone on its own: nothing is left to fail on.
Some(libc::ESRCH) if !killed.is_empty() => {}
Some(libc::ESRCH) => {
return Err(crate::safety::not_found(
"proc_kill",
"process",
&pid.to_string(),
))
}
Some(libc::EPERM) => {
return Err(crate::safety::os_denied(
"proc_kill",
format!(
"proc_kill: the operating system denied {signal} to process {pid} (EPERM){partial}"
),
"the process belongs to another user or is protected; signal a process this user owns",
))
}
_ => {
return Err(proc_kill_failed(
format!("proc_kill: kill({pid}, {signal}) failed: {err}{partial}"),
"the OS refused the signal; the message names the errno",
&err.to_string(),
))
}
}
} else {
killed.push(pid);
}
Ok(proc_kill_sent(
pid,
signal,
tree.then_some(killed.as_slice()),
))
}
}
fn bi_proc_info(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// In-process through sysinfo, the same on every OS. It was three
// shell-outs: on Windows it reported ppid 0 and state "running" for every
// process, which Get-Process never said; on Linux it split /proc/<pid>/stat
// on whitespace, so a process name containing a space shifted every field
// after it; and a pid that did not exist answered null everywhere.
let pid = pid_arg("proc_info", args.first())?;
let spid = sysinfo::Pid::from_u32(pid as u32);
let mut sys = System::new();
sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[spid]), true);
let p = sys
.process(spid)
.ok_or_else(|| crate::safety::not_found("proc_info", "process", &pid.to_string()))?;
let mut rec = BTreeMap::new();
rec.insert("pid".to_string(), Value::Int(pid));
rec.insert(
"name".to_string(),
Value::Str(p.name().to_string_lossy().to_string()),
);
rec.insert("state".to_string(), Value::Str(p.status().to_string()));
rec.insert(
"ppid".to_string(),
p.parent()
.map(|pp| Value::Int(pp.as_u32() as i64))
.unwrap_or(Value::Null),
);
rec.insert("memory_bytes".to_string(), Value::Int(p.memory() as i64));
rec.insert("start_time".to_string(), Value::Int(p.start_time() as i64));
rec.insert(
"exe".to_string(),
p.exe()
.map(|e| Value::Str(e.to_string_lossy().to_string()))
.unwrap_or(Value::Null),
);
Ok(Value::Record(rec))
}
/// Pids this shell spawned and has not yet seen end. proc_wait answers true
/// for one of them that ended before the call (a fast child is gone by the
/// time it is waited for); any other absent pid is E_NOT_FOUND. Bounded, so an
/// agent that spawns without waiting cannot grow it without limit.
static SPAWNED: std::sync::Mutex<Vec<i64>> = std::sync::Mutex::new(Vec::new());
const SPAWNED_CAP: usize = 1024;
fn spawned_note(pid: i64) {
if let Ok(mut pids) = SPAWNED.lock() {
if pids.len() >= SPAWNED_CAP {
pids.remove(0);
}
pids.push(pid);
}
}
/// Removes a pid from the spawned set; true when it was there.
fn spawned_take(pid: i64) -> bool {
match SPAWNED.lock() {
Ok(mut pids) => match pids.iter().position(|p| *p == pid) {
Some(i) => {
pids.remove(i);
true
}
None => false,
},
Err(_) => false,
}
}
/// The longest deadline `proc_spawn`'s `timeout_ms` accepts: one day.
const SPAWN_TIMEOUT_MAX_MS: i64 = 86_400_000;
/// How long a deadline stop waits for SIGTERM to empty the process group
/// before it sends SIGKILL.
#[cfg(unix)]
const SPAWN_STOP_GRACE: Duration = Duration::from_secs(2);
/// How often the deadline watcher re-reads the child. A child that ends
/// earlier is noticed within this slice.
const SPAWN_WATCH_SLICE: Duration = Duration::from_millis(100);
fn bi_proc_spawn(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
// A piped subject would be silently ignored; refuse it before anything runs.
if input.is_some() {
return Err(crate::safety::bad_arg(
"proc_spawn",
"no pipeline input",
"pipeline input",
));
}
// The options Record is the last argument when a program comes before it.
// A Record anywhere else stays an argument, and is refused below.
let (words, options) = match args.split_last() {
Some((Value::Record(rec), rest)) if !rest.is_empty() => (rest, Some(rec)),
_ => (args.as_slice(), None),
};
// Read and checked here, before the program name, the guard or any spawn.
let timeout_ms = match options {
None => None,
Some(rec) => {
cfg_known_keys("proc_spawn", rec, &["timeout_ms"])?;
cfg_int("proc_spawn", rec, "timeout_ms", 1, SPAWN_TIMEOUT_MAX_MS)?
}
};
let cmd = match words.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"proc_spawn",
"a program name (String)",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
if cmd.contains('\0') {
return Err(crate::safety::bad_arg(
"proc_spawn",
"a program name with no NUL byte",
"a NUL byte",
));
}
if cmd.len() > 4096 {
return Err(crate::safety::bad_arg(
"proc_spawn",
"a program name of at most 4096 bytes",
&format!("{} bytes", cmd.len()),
));
}
// Every argument is a string. A non-string one was dropped, and the
// program ran with the rest.
let mut spawn_args: Vec<String> = Vec::with_capacity(words.len().saturating_sub(1));
for (i, v) in words.iter().enumerate().skip(1) {
let Value::Str(s) = v else {
return Err(crate::safety::bad_arg(
"proc_spawn",
"String arguments",
&format!("{} at argument {}", v.type_name(), i + 1),
));
};
if s.contains('\0') {
return Err(crate::safety::bad_arg(
"proc_spawn",
"arguments with no NUL byte",
&format!("a NUL byte in argument {}", i + 1),
));
}
spawn_args.push(s.clone());
}
// Avoiding shell interpolation (below) stops argument injection, but the
// program itself is still caller-chosen, so this is the same capability
// `sh` grants and takes the same policy gate.
crate::safety::guard_exec("proc_spawn", format!("{} {}", cmd, spawn_args.join(" ")))?;
// Security: log the spawn attempt
eprintln!(
"[SECURITY] proc.spawn: cmd='{}', args={:?}",
cmd, spawn_args
);
// Executed directly, never through a shell (CWE-78). The child is not
// waited for here; proc_wait reaps it.
let mut command = crate::safety::child_command(&cmd);
crate::safety::strip_ironshell_env(&mut command);
command.args(&spawn_args);
// With a deadline the child leads its own process group, so the stop can
// signal the child and the descendants that stayed in its group at once.
#[cfg(unix)]
if timeout_ms.is_some() {
use std::os::unix::process::CommandExt;
command.process_group(0);
}
let child = command
.spawn()
.map_err(|e| crate::safety::spawn_error("proc_spawn", &cmd, &e))?;
let pid = i64::from(child.id());
spawned_note(pid);
if let Some(ms) = timeout_ms {
// The start time is read now, so the watcher can tell this child from
// a later process that reuses its pid. None means it has already ended,
// and there is nothing left to stop.
if let Some((start, _)) = spawn_probe(pid) {
if let Err(e) = arm_spawn_deadline(pid, start, ms) {
stop_spawn_tree_now(pid);
return Err(crate::safety::tool_failed(
"proc_spawn",
&cmd,
None,
&format!("could not start the deadline watcher ({e}); the child was stopped"),
));
}
}
}
Ok(Value::Int(pid))
}
/// The start time of a process, and whether it is still running; a zombie
/// has ended, though nothing has reaped it. It only reads the process table:
/// proc_wait is the one that reaps a child, so the watcher never takes an exit
/// status from it.
fn spawn_probe(pid: i64) -> Option<(u64, bool)> {
let spid = sysinfo::Pid::from_u32(u32::try_from(pid).ok()?);
let mut sys = System::new();
sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[spid]), true);
let proc_ = sys.process(spid)?;
Some((
proc_.start_time(),
proc_.status() != sysinfo::ProcessStatus::Zombie,
))
}
/// Whether `pid` is still the process that started at `start`, and is running.
/// A pid that has ended, or now names a different process, is not.
fn spawn_is_ours(pid: i64, start: u64) -> bool {
matches!(spawn_probe(pid), Some((s, true)) if s == start)
}
/// Starts the detached watcher for a deadline. It re-checks the child every
/// slice and returns as soon as the child has ended or its pid has changed
/// hands. At the deadline it stops the child's tree if the child is still
/// that process.
fn arm_spawn_deadline(pid: i64, start: u64, timeout_ms: i64) -> std::io::Result<()> {
let deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms as u64);
std::thread::Builder::new()
.name("proc_spawn-deadline".to_string())
.spawn(move || {
while spawn_is_ours(pid, start) {
let left = deadline.saturating_duration_since(std::time::Instant::now());
if left.is_zero() {
eprintln!(
"[proc.spawn] pid {pid} passed its {timeout_ms} ms deadline; its tree was stopped"
);
stop_spawn_tree(pid);
return;
}
std::thread::sleep(left.min(SPAWN_WATCH_SLICE));
}
})
.map(|_| ())
}
/// Stops the child and its process group: SIGTERM to the group, up to two
/// seconds for it to empty, then SIGKILL to whatever is left. The child was
/// checked to be the same running process just before this is called.
#[cfg(unix)]
fn stop_spawn_tree(pid: i64) {
let native = libc::pid_t::try_from(pid).unwrap_or(libc::pid_t::MAX);
let group_live = || {
// SAFETY: signal 0 sends nothing and touches no memory.
let rc = unsafe { libc::kill(-native, 0) };
rc == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
};
// SAFETY: kill(2) takes a pid and a signal number and touches no memory.
unsafe { libc::kill(-native, libc::SIGTERM) };
let until = std::time::Instant::now() + SPAWN_STOP_GRACE;
while group_live() && std::time::Instant::now() < until {
std::thread::sleep(Duration::from_millis(20));
}
if group_live() {
// SAFETY: as above.
unsafe { libc::kill(-native, libc::SIGKILL) };
}
}
/// The Windows stop: taskkill /T /F on the tree, once.
#[cfg(windows)]
fn stop_spawn_tree(pid: i64) {
taskkill_tree(u32::try_from(pid).unwrap_or(u32::MAX));
}
#[cfg(not(any(unix, windows)))]
fn stop_spawn_tree(_pid: i64) {}
/// The immediate stop for a child whose watcher could not be started.
#[cfg(unix)]
fn stop_spawn_tree_now(pid: i64) {
let native = libc::pid_t::try_from(pid).unwrap_or(libc::pid_t::MAX);
// SAFETY: kill(2) takes a pid and a signal number and touches no memory.
unsafe { libc::kill(-native, libc::SIGKILL) };
}
#[cfg(windows)]
fn stop_spawn_tree_now(pid: i64) {
taskkill_tree(u32::try_from(pid).unwrap_or(u32::MAX));
}
#[cfg(not(any(unix, windows)))]
fn stop_spawn_tree_now(_pid: i64) {}
/// Whether a process has ended. A child this process spawned is reaped here,
/// so a finished child is not left a zombie that `kill(pid, 0)` would still
/// find and report as running.
fn proc_has_ended(pid: i64) -> bool {
#[cfg(unix)]
{
let native = libc::pid_t::try_from(pid).unwrap_or(libc::pid_t::MAX);
let mut status: libc::c_int = 0;
// SAFETY: waitpid writes only to `status`, which lives for the call.
let reaped = unsafe { libc::waitpid(native, &mut status, libc::WNOHANG) };
if reaped == native {
return true;
}
if reaped == 0 {
return false;
}
// Not this process's child (ECHILD): ask whether the pid is still there.
// SAFETY: signal 0 sends nothing and touches no memory.
let alive = unsafe { libc::kill(native, 0) } == 0
|| std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM);
!alive
}
#[cfg(not(unix))]
{
!proc_live(pid)
}
}
fn bi_proc_wait(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
proc_check("proc_wait", &args, &input, 2)?;
let pid = proc_pid("proc_wait", args.first())?;
let timeout_ms: u64 = match args.get(1) {
None => 30_000,
Some(Value::Int(t)) if *t >= 0 => *t as u64,
Some(other) => {
return Err(crate::safety::bad_arg(
"proc_wait",
"a timeout: a non-negative Int of milliseconds",
&other.to_display_string(),
))
}
};
// A pid that is not running now was never waited for: E_NOT_FOUND. A
// process that ends while waited for is true.
if !proc_live(pid) {
// Some hosts do not list a zombie (macOS), so a child that ended before
// this call lands here and still has to be reaped.
proc_has_ended(pid);
if spawned_take(pid) {
return Ok(Value::Bool(true));
}
return Err(crate::safety::not_found(
"proc_wait",
"process",
&pid.to_string(),
));
}
// Checked at least once, so a zero timeout still answers for a live
// process; the loop ends because `waited` grows every pass.
let started = std::time::Instant::now();
loop {
if proc_has_ended(pid) {
spawned_take(pid);
return Ok(Value::Bool(true));
}
let waited = started.elapsed().as_millis();
let budget = u128::from(timeout_ms);
if waited >= budget {
return Ok(Value::Bool(false));
}
let left = (budget - waited).min(100) as u64;
std::thread::sleep(std::time::Duration::from_millis(left));
}
}
/// Asked in-process, as every other proc reader asks. `kill -0` exits 1 with
/// EPERM for a live process this user may not signal, so on Unix it called
/// init and every other user's process gone; on Windows a PowerShell that
/// failed to start read as "no such process".
fn bi_proc_exists(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let pid = pid_arg("proc_exists", args.first())?;
Ok(Value::Bool(proc_live(pid)))
}
#[cfg(test)]
mod proc_exists_tests {
use super::*;
/// Pid 1 (init) and pid 4 (System) belong to the OS, not to the tester,
/// which is the case `kill -0` answered false for.
#[test]
fn a_live_process_of_another_user_exists_and_a_free_pid_does_not() {
let own = Value::Int(i64::from(std::process::id()));
let os = Value::Int(if cfg!(windows) { 4 } else { 1 });
for (pid, want) in [(own, true), (os, true), (Value::Int(999_999_999), false)] {
let got = bi_proc_exists(vec![pid.clone()], None).unwrap();
assert!(
matches!(got, Value::Bool(b) if b == want),
"{pid:?} -> {got:?}"
);
}
}
}
/// The child process ids of a live process, from the host's own tree query.
// Was a PowerShell WMI query; now read in process through sysinfo.
#[cfg(windows)]
fn proc_child_pids(pid: i64) -> Result<Vec<i64>> {
let mut sys = System::new();
sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
let parent = sysinfo::Pid::from_u32(pid as u32);
let mut children: Vec<i64> = sys
.processes()
.iter()
.filter(|(_, proc_)| proc_.parent() == Some(parent))
.map(|(child, _)| i64::from(child.as_u32()))
.collect();
children.sort_unstable();
Ok(children)
}
#[cfg(not(windows))]
fn proc_child_pids(pid: i64) -> Result<Vec<i64>> {
let mut cmd = crate::safety::child_command("pgrep");
cmd.args(["-P", &pid.to_string()]);
let out = bounded_cmd_output("proc_children", "pgrep", cmd, PROBE_DEADLINE_SECS)?;
let text = String::from_utf8_lossy(&out.stdout).into_owned();
match out.status.code() {
Some(0) => text
.lines()
.map(|l| proc_parse::<i64>("proc_children", "pgrep", l))
.collect(),
// pgrep's 1 means nothing matched: a live process with no children.
Some(1) => Ok(vec![]),
code => Err(crate::safety::tool_failed(
"proc_children",
"pgrep",
code,
&String::from_utf8_lossy(&out.stderr),
)),
}
}
fn bi_proc_children(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
proc_check("proc_children", &args, &input, 1)?;
let pid = proc_pid("proc_children", args.first())?;
proc_require("proc_children", pid)?;
let children = proc_child_pids(pid)?;
Ok(Value::Array(children.into_iter().map(Value::Int).collect()))
}
fn bi_proc_parent(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
proc_check("proc_parent", &args, &input, 1)?;
let pid = proc_pid("proc_parent", args.first())?;
let sys = proc_sys(pid);
let process = sys
.process(sysinfo::Pid::from_u32(pid as u32))
.ok_or_else(|| crate::safety::not_found("proc_parent", "process", &pid.to_string()))?;
Ok(process
.parent()
.map(|p| Value::Int(i64::from(p.as_u32())))
.unwrap_or(Value::Null))
}
/// A Win32 FILETIME: 100 ns ticks split into two 32-bit halves.
#[cfg(windows)]
#[repr(C)]
#[derive(Default, Clone, Copy)]
struct WinFileTime {
low: u32,
high: u32,
}
#[cfg(windows)]
impl WinFileTime {
fn ticks(self) -> u64 {
(u64::from(self.high) << 32) | u64::from(self.low)
}
}
/// A Win32 THREADENTRY32, as Thread32First and Thread32Next fill it.
#[cfg(windows)]
#[repr(C)]
struct WinThreadEntry32 {
dw_size: u32,
cnt_usage: u32,
th32_thread_id: u32,
th32_owner_process_id: u32,
tp_base_pri: i32,
tp_delta_pri: i32,
dw_flags: u32,
}
/// Runs `f` with a handle to process `pid` opened with
/// PROCESS_QUERY_LIMITED_INFORMATION, then closes the handle exactly once,
/// whether `f` succeeds or not. A refused open is E_OS_DENIED, a pid that has
/// gone is E_NOT_FOUND, and anything else is E_BAD_STATE.
#[cfg(windows)]
fn win_with_process<T>(
builtin: &str,
pid: i64,
f: impl FnOnce(*mut std::ffi::c_void) -> Result<T>,
) -> Result<T> {
use std::ffi::c_void;
#[link(name = "kernel32")]
extern "system" {
fn OpenProcess(access: u32, inherit: i32, pid: u32) -> *mut c_void;
fn CloseHandle(handle: *mut c_void) -> i32;
}
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
const ERROR_ACCESS_DENIED: i32 = 5;
const ERROR_INVALID_PARAMETER: i32 = 87;
// SAFETY: OpenProcess takes plain values and returns a handle or null; the
// pid was checked to fit a u32 by proc_pid before any reader calls this.
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid as u32) };
if handle.is_null() {
let err = std::io::Error::last_os_error();
return Err(match err.raw_os_error() {
Some(ERROR_ACCESS_DENIED) => crate::safety::os_denied(
builtin,
format!("{builtin}: the operating system denied PROCESS_QUERY_LIMITED_INFORMATION to process {pid} (ERROR_ACCESS_DENIED)"),
"the process is protected or belongs to another user; read a process this user owns",
),
Some(ERROR_INVALID_PARAMETER) => {
crate::safety::not_found(builtin, "process", &pid.to_string())
}
_ => crate::safety::bad_state(
builtin,
&format!("OpenProcess on process {pid} failed: {err}"),
"a live process this user can query",
),
});
}
let result = f(handle);
// SAFETY: `handle` came from the successful OpenProcess above and is closed
// here and nowhere else.
unsafe { CloseHandle(handle) };
result
}
/// Counts the threads whose owner is `pid`, from a system thread snapshot.
#[cfg(windows)]
fn win_thread_count(builtin: &str, pid: i64) -> Result<i64> {
use std::ffi::c_void;
#[link(name = "kernel32")]
extern "system" {
fn CreateToolhelp32Snapshot(flags: u32, process: u32) -> *mut c_void;
fn Thread32First(snapshot: *mut c_void, entry: *mut WinThreadEntry32) -> i32;
fn Thread32Next(snapshot: *mut c_void, entry: *mut WinThreadEntry32) -> i32;
fn CloseHandle(handle: *mut c_void) -> i32;
}
const TH32CS_SNAPTHREAD: u32 = 0x4;
const ERROR_NO_MORE_FILES: i32 = 18;
// INVALID_HANDLE_VALUE is (HANDLE)-1.
let invalid = -1isize as *mut c_void;
// SAFETY: CreateToolhelp32Snapshot takes two plain values and returns a
// snapshot handle or INVALID_HANDLE_VALUE; the handle is closed below.
let snap = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
if snap == invalid {
return Err(crate::safety::bad_state(
builtin,
&format!(
"CreateToolhelp32Snapshot failed: {}",
std::io::Error::last_os_error()
),
"a thread snapshot of this system",
));
}
let mut entry = WinThreadEntry32 {
dw_size: std::mem::size_of::<WinThreadEntry32>() as u32,
cnt_usage: 0,
th32_thread_id: 0,
th32_owner_process_id: 0,
tp_base_pri: 0,
tp_delta_pri: 0,
dw_flags: 0,
};
let owner = pid as u32;
let mut count: i64 = 0;
let mut failure = None;
// SAFETY: `entry` is a writable THREADENTRY32 whose dw_size is set, and
// `snap` is the snapshot opened above.
let mut more = unsafe { Thread32First(snap, &mut entry) } != 0;
loop {
if !more {
// ERROR_NO_MORE_FILES is the normal end of the walk.
let err = std::io::Error::last_os_error();
if err.raw_os_error() != Some(ERROR_NO_MORE_FILES) {
failure = Some(err);
}
break;
}
if entry.th32_owner_process_id == owner {
count += 1;
}
// SAFETY: as for Thread32First above.
more = unsafe { Thread32Next(snap, &mut entry) } != 0;
}
// SAFETY: `snap` is a handle from CreateToolhelp32Snapshot, closed once.
unsafe { CloseHandle(snap) };
match failure {
Some(err) => Err(crate::safety::bad_state(
builtin,
&format!("Thread32Next failed: {err}"),
"a thread snapshot of this system",
)),
None => Ok(count),
}
}
fn bi_proc_priority(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
proc_check("proc_priority", &args, &input, 1)?;
let pid = proc_pid("proc_priority", args.first())?;
proc_require("proc_priority", pid)?;
// One record on every OS: nice from -20 (highest) to 19 (lowest), and the
// Windows priority class it corresponds to.
// Was PowerShell's Get-Process; now read in process (GetPriorityClass).
#[cfg(windows)]
let (nice, class) = {
use std::ffi::c_void;
#[link(name = "kernel32")]
extern "system" {
fn GetPriorityClass(process: *mut c_void) -> u32;
}
let raw = win_with_process("proc_priority", pid, |h| {
// SAFETY: `h` is the live handle win_with_process opened; the call
// only reads it.
let class = unsafe { GetPriorityClass(h) };
if class == 0 {
return Err(crate::safety::bad_state(
"proc_priority",
&format!(
"GetPriorityClass failed: {}",
std::io::Error::last_os_error()
),
"a live process this user can query",
));
}
Ok(class)
})?;
// Each Windows class maps to the nice value its PowerShell name had.
let (nice, name): (i64, &str) = match raw {
0x40 => (19, "Idle"),
0x4000 => (10, "BelowNormal"),
0x20 => (0, "Normal"),
0x8000 => (-5, "AboveNormal"),
0x80 => (-10, "High"),
0x100 => (-20, "RealTime"),
other => {
return Err(crate::safety::tool_failed(
"proc_priority",
"GetPriorityClass",
None,
&format!("priority class 0x{other:X} is not one this builtin knows"),
))
}
};
(nice, name.to_string())
};
#[cfg(not(windows))]
let (nice, class) = {
let out = proc_tool(
"proc_priority",
"ps",
{
let mut c = crate::safety::child_command("ps");
c.args(["-o", "ni=", "-p", &pid.to_string()]);
c
},
PROBE_DEADLINE_SECS,
)?;
let nice: i64 = proc_parse("proc_priority", "ps", &String::from_utf8_lossy(&out.stdout))?;
let class = match nice {
n if n >= 15 => "Idle",
n if n >= 5 => "BelowNormal",
n if n > -5 => "Normal",
n if n > -10 => "AboveNormal",
n if n > -15 => "High",
_ => "RealTime",
};
(nice, class.to_string())
};
let mut rec = BTreeMap::new();
rec.insert("nice".to_string(), Value::Int(nice));
rec.insert("class".to_string(), Value::Str(class));
Ok(Value::Record(rec))
}
fn bi_proc_set_priority(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
proc_check("proc_set_priority", &args, &input, 2)?;
let pid = proc_pid("proc_set_priority", args.first())?;
let priority = match args.get(1) {
Some(Value::Int(p)) => *p,
// A non-numeric string was refused, and a missing priority was 0.
Some(Value::Str(s)) => s
.trim()
.parse::<i64>()
.map_err(|_| crate::safety::bad_arg("proc_set_priority", "an integer priority", s))?,
other => {
return Err(crate::safety::bad_arg(
"proc_set_priority",
"Int or String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
// nice's range. Outside it the OS clamps the value and says nothing.
if !(-20..=19).contains(&priority) {
return Err(crate::safety::bad_arg(
"proc_set_priority",
"a priority from -20 (highest) to 19 (lowest)",
&priority.to_string(),
));
}
proc_require("proc_set_priority", pid)?;
#[cfg(windows)]
{
let class = match priority {
p if p < -10 => "High",
p if p < 0 => "AboveNormal",
0 => "Normal",
p if p < 10 => "BelowNormal",
_ => "Idle",
};
let cmd = crate::ps_script!(
"(Get-Process -Id {}).PriorityClass = {}",
pid,
crate::safety::ps_quote(class)
);
let mut bounded_cmd = crate::safety::child_command("powershell");
bounded_cmd.args(["-Command", &cmd]);
let out = bounded_cmd_output(
"proc_set_priority",
"powershell",
bounded_cmd,
TOOL_DEADLINE_SECS,
)?;
if !out.status.success() {
return Err(proc_refused(
"proc_set_priority",
"powershell",
pid,
&out.stderr,
out.status.code(),
));
}
}
#[cfg(not(windows))]
{
let mut cmd = crate::safety::child_command("renice");
cmd.args(["-n", &priority.to_string(), "-p", &pid.to_string()]);
let out = bounded_cmd_output("proc_set_priority", "renice", cmd, TOOL_DEADLINE_SECS)?;
if !out.status.success() {
return Err(proc_refused(
"proc_set_priority",
"renice",
pid,
&out.stderr,
out.status.code(),
));
}
}
Ok(Value::Bool(true))
}
fn bi_proc_cpu_usage(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
proc_check("proc_cpu_usage", &args, &input, 1)?;
let pid = proc_pid("proc_cpu_usage", args.first())?;
proc_require("proc_cpu_usage", pid)?;
// Units differ by OS: ps %CPU (percent of one CPU) on Unix, total CPU
// seconds from GetProcessTimes on Windows. The builtin says so in its doc.
// Was PowerShell's Get-Process; now read in process (GetProcessTimes).
#[cfg(windows)]
let cpu: f64 = win_with_process("proc_cpu_usage", pid, |h| {
use std::ffi::c_void;
#[link(name = "kernel32")]
extern "system" {
fn GetProcessTimes(
process: *mut c_void,
creation: *mut WinFileTime,
exit: *mut WinFileTime,
kernel: *mut WinFileTime,
user: *mut WinFileTime,
) -> i32;
}
let mut creation = WinFileTime::default();
let mut exit = WinFileTime::default();
let mut kernel = WinFileTime::default();
let mut user = WinFileTime::default();
// SAFETY: `h` is the live handle win_with_process opened; each
// out-pointer is a valid, writable FILETIME.
if unsafe { GetProcessTimes(h, &mut creation, &mut exit, &mut kernel, &mut user) } == 0 {
return Err(crate::safety::bad_state(
"proc_cpu_usage",
&format!(
"GetProcessTimes failed: {}",
std::io::Error::last_os_error()
),
"a live process this user can query",
));
}
// Kernel plus user time in 100 ns ticks: the total Get-Process reported.
Ok((kernel.ticks() + user.ticks()) as f64 / 1e7)
})?;
#[cfg(not(windows))]
let cpu: f64 = {
let out = proc_tool(
"proc_cpu_usage",
"ps", // LC_ALL=C: a decimal comma would not parse.
{
let mut c = crate::safety::child_command("ps");
c.env("LC_ALL", "C")
.args(["-o", "%cpu=", "-p", &pid.to_string()]);
c
},
PROBE_DEADLINE_SECS,
)?;
proc_parse(
"proc_cpu_usage",
"ps",
&String::from_utf8_lossy(&out.stdout),
)?
};
Ok(Value::Float(cpu))
}
fn bi_proc_mem_usage(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
proc_check("proc_mem_usage", &args, &input, 1)?;
let pid = proc_pid("proc_mem_usage", args.first())?;
proc_require("proc_mem_usage", pid)?;
// Was PowerShell's Get-Process; now read in process through sysinfo.
#[cfg(windows)]
let bytes: i64 = {
let sys = proc_sys(pid);
let process = sys
.process(sysinfo::Pid::from_u32(pid as u32))
.ok_or_else(|| {
crate::safety::not_found("proc_mem_usage", "process", &pid.to_string())
})?;
i64::try_from(process.memory()).unwrap_or(i64::MAX)
};
#[cfg(not(windows))]
let bytes: i64 = {
let out = proc_tool(
"proc_mem_usage",
"ps",
{
let mut c = crate::safety::child_command("ps");
c.args(["-o", "rss=", "-p", &pid.to_string()]);
c
},
PROBE_DEADLINE_SECS,
)?;
// ps reports resident kilobytes.
proc_parse::<i64>(
"proc_mem_usage",
"ps",
&String::from_utf8_lossy(&out.stdout),
)?
.saturating_mul(1024)
};
Ok(Value::Int(bytes))
}
fn bi_proc_threads(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
proc_check("proc_threads", &args, &input, 1)?;
let pid = proc_pid("proc_threads", args.first())?;
proc_require("proc_threads", pid)?;
// Was PowerShell's Get-Process; now counted in process from a thread snapshot.
#[cfg(windows)]
let threads: i64 = win_thread_count("proc_threads", pid)?;
#[cfg(target_os = "linux")]
let threads: i64 = {
let entries = std::fs::read_dir(format!("/proc/{pid}/task"))
.map_err(|e| proc_read_failed("proc_threads", pid, "its threads", &e))?;
let mut n: i64 = 0;
for entry in entries {
entry.map_err(|e| proc_read_failed("proc_threads", pid, "its threads", &e))?;
n += 1;
}
n
};
#[cfg(all(not(windows), not(target_os = "linux")))]
let threads: i64 = {
let out = proc_tool(
"proc_threads",
"ps",
{
let mut c = crate::safety::child_command("ps");
c.args(["-M", "-p", &pid.to_string()]);
c
},
PROBE_DEADLINE_SECS,
)?;
// ps -M prints a header, then one line per thread.
let lines = String::from_utf8_lossy(&out.stdout).lines().count();
i64::try_from(lines.saturating_sub(1)).unwrap_or(i64::MAX)
};
Ok(Value::Int(threads))
}
#[cfg(target_os = "linux")]
fn proc_env_of(pid: i64) -> Result<BTreeMap<String, Value>> {
let data = std::fs::read(format!("/proc/{pid}/environ"))
.map_err(|e| proc_read_failed("proc_env", pid, "its environment", &e))?;
Ok(data
.split(|&b| b == 0)
.filter(|entry| !entry.is_empty())
.filter_map(|entry| {
let text = String::from_utf8_lossy(entry);
let (k, v) = text.split_once('=')?;
Some((k.to_string(), Value::Str(v.to_string())))
})
.collect())
}
/// A process's `sysinfo` record with its environment and working directory
/// refreshed, for one pid. Windows reads both through the process handle and
/// macOS through sysctl KERN_PROCARGS2, so a protected or another user's
/// process comes back with an empty environment (and, on Windows, no
/// directory); the readers turn that into E_OS_DENIED.
#[cfg(any(windows, target_os = "macos"))]
fn proc_sys_env_cwd(pid: i64) -> System {
let spid = sysinfo::Pid::from_u32(pid as u32);
let mut sys = System::new();
let kind = sysinfo::ProcessRefreshKind::nothing()
.with_environ(sysinfo::UpdateKind::Always)
.with_cwd(sysinfo::UpdateKind::Always);
sys.refresh_processes_specifics(sysinfo::ProcessesToUpdate::Some(&[spid]), true, kind);
sys
}
/// E_OS_DENIED for a live process whose environment or directory the OS
/// would not give us.
#[cfg(any(windows, target_os = "macos"))]
fn proc_access_denied(builtin: &str, what: &str, pid: i64) -> anyhow::Error {
crate::safety::os_denied(
builtin,
format!("{builtin}: {what} of process {pid} is not readable"),
"the process is protected or belongs to another user; read a process this user owns",
)
}
#[cfg(any(windows, target_os = "macos"))]
fn proc_env_of(pid: i64) -> Result<BTreeMap<String, Value>> {
let sys = proc_sys_env_cwd(pid);
let process = sys
.process(sysinfo::Pid::from_u32(pid as u32))
.ok_or_else(|| crate::safety::not_found("proc_env", "process", &pid.to_string()))?;
if process.environ().is_empty() {
return Err(proc_access_denied("proc_env", "the environment", pid));
}
Ok(process
.environ()
.iter()
.filter_map(|entry| {
let text = entry.to_string_lossy();
let (k, v) = text.split_once('=')?;
// cmd keeps hidden per-drive entries named "=C:"; they are not variables.
if k.is_empty() {
return None;
}
Some((k.to_string(), Value::Str(v.to_string())))
})
.collect())
}
#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
fn proc_env_of(pid: i64) -> Result<BTreeMap<String, Value>> {
Err(crate::safety::unimplemented(
"proc_env",
&format!("reading the environment of process {pid} on this OS"),
"this shell's own environment is available from env() and sys_env_all(); other processes' environments are readable on Linux, macOS and Windows",
))
}
fn bi_proc_env(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
proc_check("proc_env", &args, &input, 1)?;
let pid = proc_pid("proc_env", args.first())?;
// Both branches answer through `gate_env_secret`, as `env()` and `env_vars()`
// do: a secret-named variable of another process is a credential as well.
let rec: BTreeMap<String, Value> = if proc_is_self(pid) {
// This shell's own environment, answered in-process on every OS.
std::env::vars_os()
.map(|(k, v)| {
let k = k.to_string_lossy().into_owned();
let gated = gate_env_secret(&k, v.to_string_lossy().into_owned());
(k, gated)
})
.collect()
} else {
proc_require("proc_env", pid)?;
proc_env_of(pid)?
.into_iter()
.map(|(k, v)| {
let gated = match v {
Value::Str(s) => gate_env_secret(&k, s),
other => other,
};
(k, gated)
})
.collect()
};
Ok(Value::Record(rec))
}
#[cfg(target_os = "linux")]
fn proc_cwd_of(pid: i64) -> Result<String> {
let path = std::fs::read_link(format!("/proc/{pid}/cwd"))
.map_err(|e| proc_read_failed("proc_cwd", pid, "its working directory", &e))?;
Ok(path.to_string_lossy().into_owned())
}
#[cfg(target_os = "macos")]
fn proc_cwd_of(pid: i64) -> Result<String> {
let out = proc_tool(
"proc_cwd",
"lsof",
{
let mut c = crate::safety::child_command("lsof");
c.args(["-p", &pid.to_string(), "-Fn", "-d", "cwd"]);
c
},
PROBE_DEADLINE_SECS,
)?;
let text = String::from_utf8_lossy(&out.stdout).into_owned();
let named = text
.lines()
.find_map(|l| l.strip_prefix('n').map(str::to_string));
named.ok_or_else(|| {
crate::safety::tool_failed(
"proc_cwd",
"lsof",
Some(0),
"its output named no working directory",
)
})
}
#[cfg(windows)]
fn proc_cwd_of(pid: i64) -> Result<String> {
let sys = proc_sys_env_cwd(pid);
let process = sys
.process(sysinfo::Pid::from_u32(pid as u32))
.ok_or_else(|| crate::safety::not_found("proc_cwd", "process", &pid.to_string()))?;
match process.cwd() {
Some(dir) if !dir.as_os_str().is_empty() => Ok(dir.to_string_lossy().into_owned()),
_ => Err(proc_access_denied("proc_cwd", "the working directory", pid)),
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
fn proc_cwd_of(pid: i64) -> Result<String> {
Err(crate::safety::unimplemented(
"proc_cwd",
&format!("the working directory of process {pid} on this OS"),
"this shell's own working directory is pwd()",
))
}
fn bi_proc_cwd(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
proc_check("proc_cwd", &args, &input, 1)?;
let pid = proc_pid("proc_cwd", args.first())?;
if proc_is_self(pid) {
let dir = std::env::current_dir().map_err(|e| {
crate::safety::tool_failed("proc_cwd", "current_dir", Some(0), &e.to_string())
})?;
return Ok(Value::Str(dir.to_string_lossy().into_owned()));
}
proc_require("proc_cwd", pid)?;
Ok(Value::Str(proc_cwd_of(pid)?))
}
// Was PowerShell's Get-Process; now read in process through sysinfo.
#[cfg(windows)]
fn proc_exe_of(pid: i64) -> Result<String> {
let sys = proc_sys(pid);
let path = sys
.process(sysinfo::Pid::from_u32(pid as u32))
.and_then(|process| process.exe())
.map(|exe| exe.to_string_lossy().trim().to_string())
.unwrap_or_default();
if path.is_empty() {
return Err(crate::safety::os_denied(
"proc_exe",
format!("proc_exe: the path of process {pid} is not readable"),
"the process is protected or belongs to another user; read the path of a process this user owns",
));
}
Ok(path)
}
#[cfg(target_os = "linux")]
fn proc_exe_of(pid: i64) -> Result<String> {
let path = std::fs::read_link(format!("/proc/{pid}/exe"))
.map_err(|e| proc_read_failed("proc_exe", pid, "its executable", &e))?;
Ok(path.to_string_lossy().into_owned())
}
#[cfg(all(not(windows), not(target_os = "linux")))]
fn proc_exe_of(pid: i64) -> Result<String> {
let out = proc_tool(
"proc_exe",
"ps",
{
let mut c = crate::safety::child_command("ps");
c.args(["-p", &pid.to_string(), "-o", "comm="]);
c
},
PROBE_DEADLINE_SECS,
)?;
let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
if path.is_empty() {
return Err(crate::safety::tool_failed(
"proc_exe",
"ps",
Some(0),
"its output named no executable",
));
}
Ok(path)
}
fn bi_proc_exe(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
proc_check("proc_exe", &args, &input, 1)?;
let pid = proc_pid("proc_exe", args.first())?;
proc_require("proc_exe", pid)?;
Ok(Value::Str(proc_exe_of(pid)?))
}
// Was `(Get-CimInstance Win32_Process -Filter ProcessId=N).CommandLine` run
// through PowerShell and split on whitespace here; now read in process.
// sysinfo's cmd() is the command line as CommandLineToArgvW splits it, so each
// element is one argument.
#[cfg(windows)]
fn proc_cmdline_of(pid: i64) -> Result<Vec<String>> {
let spid = sysinfo::Pid::from_u32(pid as u32);
let mut sys = System::new();
sys.refresh_processes_specifics(
sysinfo::ProcessesToUpdate::Some(&[spid]),
true,
sysinfo::ProcessRefreshKind::nothing().with_cmd(sysinfo::UpdateKind::Always),
);
let argv: Vec<String> = sys
.process(spid)
.map(|process| {
process
.cmd()
.iter()
.map(|arg| arg.to_string_lossy().into_owned())
.collect()
})
.unwrap_or_default();
if argv.is_empty() {
return Err(crate::safety::os_denied(
"proc_cmdline",
format!("proc_cmdline: the command line of process {pid} is not readable"),
"the process is protected or belongs to another user; read the command line of a process this user owns",
));
}
Ok(argv)
}
#[cfg(target_os = "linux")]
fn proc_cmdline_of(pid: i64) -> Result<Vec<String>> {
let data = std::fs::read(format!("/proc/{pid}/cmdline"))
.map_err(|e| proc_read_failed("proc_cmdline", pid, "its command line", &e))?;
// A zombie or kernel thread has no arguments at all.
if data.is_empty() {
return Ok(vec![]);
}
// Every argument ends in a NUL, so drop the one trailing terminator and
// split on the rest. An empty argument is a real argument, not noise.
let body: &[u8] = data.strip_suffix(&[0u8][..]).unwrap_or(&data[..]);
Ok(body
.split(|&b| b == 0)
.map(|arg| String::from_utf8_lossy(arg).into_owned())
.collect())
}
#[cfg(all(not(windows), not(target_os = "linux")))]
fn proc_cmdline_of(pid: i64) -> Result<Vec<String>> {
let out = proc_tool(
"proc_cmdline",
"ps",
{
let mut c = crate::safety::child_command("ps");
c.args(["-p", &pid.to_string(), "-o", "args="]);
c
},
PROBE_DEADLINE_SECS,
)?;
let line = String::from_utf8_lossy(&out.stdout).trim().to_string();
if line.is_empty() {
return Err(crate::safety::tool_failed(
"proc_cmdline",
"ps",
Some(0),
"its output named no command line",
));
}
// ps does not quote its output, so an argument with a space is split.
let argv: Vec<String> = line.split_whitespace().map(str::to_string).collect();
Ok(argv)
}
fn bi_proc_cmdline(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
proc_check("proc_cmdline", &args, &input, 1)?;
let pid = proc_pid("proc_cmdline", args.first())?;
proc_require("proc_cmdline", pid)?;
let argv = proc_cmdline_of(pid)?;
Ok(Value::Array(argv.into_iter().map(Value::Str).collect()))
}
/// Seconds since the Unix epoch, as UTC ISO 8601 to the second, or None when
/// the value is out of chrono's range. The one date form the shell answers in:
/// `ps lstart` and `ToString('o')` gave three different spellings, and the
/// Unix one changed with the locale.
fn utc_iso_seconds(secs: i64) -> Option<String> {
chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0)
.map(|at| at.format("%Y-%m-%dT%H:%M:%SZ").to_string())
}
/// Seconds since the Unix epoch, as UTC ISO 8601 to the second. One form on
/// every OS: see `utc_iso_seconds`.
fn proc_utc_iso(pid: i64, secs: u64) -> Result<String> {
i64::try_from(secs)
.ok()
.and_then(utc_iso_seconds)
.ok_or_else(|| {
crate::safety::tool_failed(
"proc_start_time",
"sysinfo",
Some(0),
&format!("the start time of process {pid} is out of range"),
)
})
}
fn bi_proc_start_time(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
proc_check("proc_start_time", &args, &input, 1)?;
let pid = proc_pid("proc_start_time", args.first())?;
let sys = proc_sys(pid);
let secs = sys
.process(sysinfo::Pid::from_u32(pid as u32))
.ok_or_else(|| crate::safety::not_found("proc_start_time", "process", &pid.to_string()))?
.start_time();
Ok(Value::Str(proc_utc_iso(pid, secs)?))
}
/// Sends a stop or continue signal. Unix sends it with kill(2), as proc_kill
/// does. Windows opens the process with PROCESS_SUSPEND_RESUME and calls
/// ntdll's NtSuspendProcess or NtResumeProcess: see the Windows definition.
#[cfg(unix)]
fn proc_signal(builtin: &str, pid: i64, name: &str) -> Result<Value> {
let sig = if name == "STOP" {
libc::SIGSTOP
} else {
libc::SIGCONT
};
let native = libc::pid_t::try_from(pid).map_err(|_| {
crate::safety::bad_arg(
builtin,
"a process id that fits the operating system's pid type",
&pid.to_string(),
)
})?;
// SAFETY: kill(2) takes a pid and a signal number and touches no memory.
if unsafe { libc::kill(native, sig) } == 0 {
return Ok(Value::Bool(true));
}
let err = std::io::Error::last_os_error();
Err(match err.raw_os_error() {
Some(libc::ESRCH) => crate::safety::not_found(builtin, "process", &pid.to_string()),
Some(libc::EPERM) => crate::safety::os_denied(
builtin,
format!("{builtin}: the operating system denied SIG{name} to process {pid} (EPERM)"),
"the process belongs to another user or is protected; signal a process this user owns",
),
_ => crate::safety::tool_failed(
builtin,
&format!("kill({pid}, SIG{name})"),
None,
&err.to_string(),
),
})
}
/// The Windows half of [`proc_signal`]. A PowerShell script, built with
/// `ps_script!` so no caller text reaches it, opens the process with
/// PROCESS_SUSPEND_RESUME (0x0800), calls NtSuspendProcess or NtResumeProcess
/// through Add-Type P/Invoke, and closes the handle. It prints one coded line:
/// `ok`, `err:open:<GetLastError>` or `err:nt:<NTSTATUS in hex>`. That line is
/// the only thing read back; prose from PowerShell is never parsed.
#[cfg(windows)]
fn proc_signal(builtin: &str, pid: i64, name: &str) -> Result<Value> {
const PRELUDE: &str = "$ErrorActionPreference = 'Stop'; Add-Type -Namespace IronShellProc -Name Native -MemberDefinition '[DllImport(\"kernel32.dll\", SetLastError=true)] public static extern IntPtr OpenProcess(uint access, bool inherit, uint pid); [DllImport(\"kernel32.dll\", SetLastError=true)] public static extern bool CloseHandle(IntPtr handle); [DllImport(\"ntdll.dll\")] public static extern int NtSuspendProcess(IntPtr handle); [DllImport(\"ntdll.dll\")] public static extern int NtResumeProcess(IntPtr handle);'; ";
let nt: &'static str = if name == "STOP" {
"NtSuspendProcess"
} else {
"NtResumeProcess"
};
let cmd = crate::ps_script!(
"{}$h = [IronShellProc.Native]::OpenProcess(0x0800, $false, {}); if ($h -eq [IntPtr]::Zero) {{ 'err:open:' + [Runtime.InteropServices.Marshal]::GetLastWin32Error() }} else {{ $s = [IronShellProc.Native]::{}($h); [void][IronShellProc.Native]::CloseHandle($h); if ($s -eq 0) {{ 'ok' }} else {{ 'err:nt:' + $s.ToString('X8') }} }}",
PRELUDE,
pid,
nt
);
let mut c = crate::safety::child_command("powershell");
c.args(["-NoProfile", "-NonInteractive", "-Command", &cmd]);
let out = bounded_cmd_output(builtin, "powershell", c, TOOL_DEADLINE_SECS)?;
let stdout = String::from_utf8_lossy(&out.stdout);
let verdict = stdout
.lines()
.map(str::trim)
.find(|l| *l == "ok" || l.starts_with("err:"));
match verdict {
Some("ok") => Ok(Value::Bool(true)),
Some("err:open:5") => Err(crate::safety::os_denied(
builtin,
format!("{builtin}: the operating system denied PROCESS_SUSPEND_RESUME to process {pid} (ERROR_ACCESS_DENIED)"),
"the process is protected or belongs to another user; act on a process this user owns",
)),
Some("err:open:87") => Err(crate::safety::not_found(
builtin,
"process",
&pid.to_string(),
)),
Some(line) if line.starts_with("err:open:") => Err(crate::safety::tool_failed(
builtin,
"OpenProcess",
None,
&format!("OpenProcess on process {pid} failed: {line}"),
)),
Some(line) if line.starts_with("err:nt:") => Err(crate::safety::tool_failed(
builtin,
nt,
None,
&format!(
"{nt} on process {pid} returned NTSTATUS 0x{}",
&line["err:nt:".len()..]
),
)),
_ => Err(crate::safety::tool_failed(
builtin,
"powershell",
out.status.code(),
&String::from_utf8_lossy(&out.stderr),
)),
}
}
#[cfg(not(any(unix, windows)))]
fn proc_signal(builtin: &str, pid: i64, name: &str) -> Result<Value> {
Err(crate::safety::unimplemented(
builtin,
&format!("sending the {name} signal to process {pid}: this platform has no suspend or resume call in this shell"),
"use proc_kill to end the process",
))
}
fn bi_proc_suspend(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
proc_check("proc_suspend", &args, &input, 1)?;
let pid = proc_pid("proc_suspend", args.first())?;
proc_refuse_self("proc_suspend", pid)?;
proc_require("proc_suspend", pid)?;
proc_signal("proc_suspend", pid, "STOP")
}
fn bi_proc_resume(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
proc_check("proc_resume", &args, &input, 1)?;
let pid = proc_pid("proc_resume", args.first())?;
proc_refuse_self("proc_resume", pid)?;
proc_require("proc_resume", pid)?;
proc_signal("proc_resume", pid, "CONT")
}
// ============================================================================
// 250-269: Filesystem Extended
// ============================================================================
fn bi_fs_stat(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"fs_stat",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
// A missing path is the documented Null. The old code returned Null for
// any metadata error, so a permission or I/O failure read as absence.
let meta = match std::fs::metadata(&path) {
Ok(meta) => meta,
Err(e) if path_is_absent(&e) => return Ok(Value::Null),
Err(e) => return Err(crate::safety::fs_error("fs_stat", &path, &e)),
};
let mut rec = std::collections::BTreeMap::new();
rec.insert("size".to_string(), Value::Int(meta.len() as i64));
rec.insert("is_file".to_string(), Value::Bool(meta.is_file()));
rec.insert("is_dir".to_string(), Value::Bool(meta.is_dir()));
rec.insert(
"is_symlink".to_string(),
Value::Bool(meta.file_type().is_symlink()),
);
rec.insert(
"readonly".to_string(),
Value::Bool(meta.permissions().readonly()),
);
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
rec.insert("mode".to_string(), Value::Int(meta.mode() as i64));
rec.insert("uid".to_string(), Value::Int(meta.uid() as i64));
rec.insert("gid".to_string(), Value::Int(meta.gid() as i64));
rec.insert("inode".to_string(), Value::Int(meta.ino() as i64));
rec.insert("nlink".to_string(), Value::Int(meta.nlink() as i64));
rec.insert("blocks".to_string(), Value::Int(meta.blocks() as i64));
rec.insert("blksize".to_string(), Value::Int(meta.blksize() as i64));
}
if let Ok(modified) = meta.modified() {
if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
rec.insert(
"modified".to_string(),
Value::Int(duration.as_secs() as i64),
);
}
}
if let Ok(accessed) = meta.accessed() {
if let Ok(duration) = accessed.duration_since(std::time::UNIX_EPOCH) {
rec.insert(
"accessed".to_string(),
Value::Int(duration.as_secs() as i64),
);
}
}
if let Ok(created) = meta.created() {
if let Ok(duration) = created.duration_since(std::time::UNIX_EPOCH) {
rec.insert("created".to_string(), Value::Int(duration.as_secs() as i64));
}
}
Ok(Value::Record(rec))
}
fn bi_fs_lstat(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"fs_lstat",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
// Same rule as fs_stat: only a missing path is Null.
let meta = match std::fs::symlink_metadata(&path) {
Ok(meta) => meta,
Err(e) if path_is_absent(&e) => return Ok(Value::Null),
Err(e) => return Err(crate::safety::fs_error("fs_lstat", &path, &e)),
};
let mut rec = std::collections::BTreeMap::new();
rec.insert("size".to_string(), Value::Int(meta.len() as i64));
rec.insert("is_file".to_string(), Value::Bool(meta.is_file()));
rec.insert("is_dir".to_string(), Value::Bool(meta.is_dir()));
rec.insert(
"is_symlink".to_string(),
Value::Bool(meta.file_type().is_symlink()),
);
Ok(Value::Record(rec))
}
fn bi_fs_chmod(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
guard_and_set_permission_mode("fs_chmod", args, input)
}
fn guard_and_set_permission_mode(
builtin: &'static str,
args: Vec<Value>,
input: Option<Value>,
) -> Result<Value> {
if args.len() != 2 || input.is_some() {
return Err(crate::safety::arg_err(format!(
"{builtin} requires exactly path and mode, without pipeline input"
)));
}
let mut args = args.into_iter();
let Some(Value::Str(path)) = args.next() else {
return Err(crate::safety::arg_err(format!(
"{builtin} path must be String"
)));
};
let mode = match args.next() {
Some(Value::Int(n)) if (0..=0o7777).contains(&n) => n as u32,
Some(Value::Str(text)) => {
let digits = text.strip_prefix("0o").unwrap_or(&text);
if digits.is_empty() || !digits.bytes().all(|b| (b'0'..=b'7').contains(&b)) {
return Err(crate::safety::arg_err(format!(
"{builtin} mode must contain octal digits, optionally prefixed by 0o"
)));
}
let mode = u32::from_str_radix(digits, 8)
.map_err(|_| crate::safety::arg_err(format!("{builtin} mode is too large")))?;
if mode > 0o7777 {
return Err(crate::safety::arg_err(format!(
"{builtin} mode must be in 0..=07777"
)));
}
mode
}
_ => {
return Err(crate::safety::arg_err(format!(
"{builtin} mode must be Int in 0..=07777 or octal String"
)))
}
};
let path = guard_local_write(builtin, &path)?;
crate::tx::snapshot_metadata(&path);
let metadata = fs::metadata(&path).map_err(|e| crate::safety::fs_error(builtin, &path, &e))?;
let mut permissions = metadata.permissions();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
permissions.set_mode(mode);
}
#[cfg(not(unix))]
{
// The Windows contract changes the read-only attribute only.
permissions.set_readonly(mode & 0o200 == 0);
}
fs::set_permissions(&path, permissions)
.map_err(|e| crate::safety::fs_error(builtin, &path, &e))?;
Ok(Value::Bool(true))
}
fn bi_fs_chown(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
if !(2..=3).contains(&args.len()) || input.is_some() {
return Err(crate::safety::arg_err(
"fs_chown requires path, owner ID and optional group ID, without pipeline input",
));
}
// The two platforms' bodies are inline so the guard each calls is in this
// function's body, where the SELF_GUARDED check reads it.
#[cfg(windows)]
{
// Windows: set the owner of one file to a named account through the
// native security API. The owner is an account name String, because a
// numeric ID has no Windows meaning; there is no group argument (chgrp
// sets the group). A call inside a transaction is refused, because the
// prior owner is not journaled.
let mut args = args.into_iter();
let Some(Value::Str(path)) = args.next() else {
return Err(crate::safety::arg_err("fs_chown path must be String"));
};
let owner = match args.next() {
Some(Value::Str(name)) => name,
Some(other) => {
return Err(crate::safety::bad_arg(
"fs_chown",
"owner: an account or group name, as a String",
other.type_name(),
))
}
None => {
return Err(crate::safety::arg_err(
"fs_chown requires an owner account name",
))
}
};
if args.next().is_some() {
return Err(crate::safety::bad_arg(
"fs_chown",
"path and owner only (Windows sets no group here; use chgrp)",
"a third argument",
));
}
windows_account_arg("fs_chown", &owner)?;
let path = crate::safety::resolve_path_str(&path);
let path = guard_local_write("fs_chown", &path)?;
std::fs::symlink_metadata(&path)
.map_err(|e| crate::safety::fs_error("fs_chown", &path, &e))?;
windows_refuse_untracked_change("fs_chown")?;
let mut sid = windows_account_sid("fs_chown", &owner)?;
windows_set_file_principal("fs_chown", &path, &mut sid, false)?;
Ok(Value::Bool(true))
}
#[cfg(not(windows))]
{
fn id(value: Value) -> Result<u32> {
match value {
Value::Int(n) if (0..i64::from(u32::MAX)).contains(&n) => Ok(n as u32),
_ => Err(crate::safety::arg_err("fs_chown IDs must be Int in 0..4294967295, excluding the reserved unchanged sentinel")),
}
}
let mut args = args.into_iter();
let Some(Value::Str(path)) = args.next() else {
return Err(crate::safety::arg_err("fs_chown path must be String"));
};
let owner = args
.next()
.map(id)
.transpose()?
.ok_or_else(|| crate::safety::arg_err("fs_chown requires owner ID"))?;
let group = args.next().map(id).transpose()?;
let path = crate::safety::resolve_path_str(&path);
#[cfg(unix)]
let path = fs::canonicalize(&path)
.map_err(|e| crate::safety::fs_error("fs_chown", &path, &e))?
.into_os_string()
.into_string()
.map_err(|_| crate::safety::arg_err("fs_chown resolved target must be valid UTF-8"))?;
let path = guard_local_write("fs_chown", &path)?;
crate::tx::snapshot_metadata(&path);
#[cfg(unix)]
{
std::os::unix::fs::chown(&path, Some(owner), group)
.map_err(|e| crate::safety::fs_error("fs_chown", &path, &e))?;
Ok(Value::Bool(true))
}
#[cfg(not(unix))]
{
let _ = (path, owner, group);
Err(crate::safety::unimplemented(
"fs_chown",
"Unix numeric ownership is unavailable on this platform",
"use the platform's ownership and access-control tools",
))
}
}
}
/// chgrp on Windows: set the primary group of one file to a named account or
/// group. Same guard, existence and transaction checks as fs_chown; the record
/// is {group, path} with the path as the caller wrote it, as on Unix.
#[cfg(windows)]
fn chgrp_windows(group: String, path: String) -> Result<Value> {
windows_account_arg("chgrp", &group)?;
let target = guard_local_write("chgrp", &path)?;
std::fs::symlink_metadata(&target)
.map_err(|e| crate::safety::fs_error("chgrp", &target, &e))?;
windows_refuse_untracked_change("chgrp")?;
let mut sid = windows_account_sid("chgrp", &group)?;
windows_set_file_principal("chgrp", &target, &mut sid, true)?;
let mut rec = BTreeMap::new();
rec.insert("group".to_string(), Value::Str(group));
rec.insert("path".to_string(), Value::Str(path));
Ok(Value::Record(rec))
}
/// Windows owner and group arguments are account names. A numeric ID has no
/// Windows meaning, so it is refused rather than looked up as a name.
#[cfg(windows)]
fn windows_account_arg(builtin: &str, name: &str) -> Result<()> {
if name.trim().is_empty() || name.contains('\0') {
return Err(crate::safety::bad_arg(
builtin,
"an account or group name, not empty",
"an empty or NUL-containing String",
));
}
if name.bytes().all(|b| b.is_ascii_digit()) {
return Err(crate::safety::bad_arg(
builtin,
"an account or group name",
"a numeric ID, which Windows does not use",
));
}
Ok(())
}
/// A Windows owner or group change is not journaled, so rollback could not
/// undo it. Inside a transaction it is refused before anything changes.
#[cfg(windows)]
fn windows_refuse_untracked_change(builtin: &str) -> Result<()> {
if crate::tx::is_active() {
return Err(crate::safety::unimplemented(
builtin,
"a Windows owner or group change cannot be rolled back; nothing was changed",
"finish the transaction first",
));
}
Ok(())
}
/// The SID of a named account, through LookupAccountNameW's two-call pattern:
/// a sizing call with no buffers, then the call with owned buffers. The SID is
/// returned as a Vec<u32> so that it is 4-byte aligned, as a SID is.
#[cfg(windows)]
pub(crate) fn windows_account_sid(builtin: &str, name: &str) -> Result<Vec<u32>> {
use std::ffi::c_void;
#[link(name = "advapi32")]
extern "system" {
fn LookupAccountNameW(
system: *const u16,
account: *const u16,
sid: *mut c_void,
sid_len: *mut u32,
domain: *mut u16,
domain_len: *mut u32,
kind: *mut i32,
) -> i32;
}
const ERROR_INSUFFICIENT_BUFFER: i32 = 122;
const ERROR_NONE_MAPPED: i32 = 1332;
let account: Vec<u16> = name.encode_utf16().chain(std::iter::once(0)).collect();
let mut sid_len: u32 = 0;
let mut domain_len: u32 = 0;
let mut kind: i32 = 0;
// SAFETY: `account` is NUL-terminated. With no buffers the call only
// reports the sizes it needs, and fails with ERROR_INSUFFICIENT_BUFFER, or
// ERROR_NONE_MAPPED for an account it does not know.
unsafe {
LookupAccountNameW(
std::ptr::null(),
account.as_ptr(),
std::ptr::null_mut(),
&mut sid_len,
std::ptr::null_mut(),
&mut domain_len,
&mut kind,
)
};
let sizing = std::io::Error::last_os_error();
match sizing.raw_os_error() {
Some(ERROR_INSUFFICIENT_BUFFER) => {}
Some(ERROR_NONE_MAPPED) => return Err(crate::safety::not_found(builtin, "account", name)),
_ => {
return Err(crate::safety::bad_state(
builtin,
&format!("LookupAccountNameW failed: {sizing}"),
"an account name this machine or its domain knows",
))
}
}
let mut sid = vec![0u32; (sid_len as usize).div_ceil(4).max(1)];
let mut domain = vec![0u16; (domain_len as usize).max(1)];
let mut domain_cch = domain.len() as u32;
// SAFETY: the SID buffer holds at least `sid_len` bytes, the domain buffer
// holds `domain_cch` UTF-16 units, and both lengths are passed by reference.
let ok = unsafe {
LookupAccountNameW(
std::ptr::null(),
account.as_ptr(),
sid.as_mut_ptr().cast(),
&mut sid_len,
domain.as_mut_ptr(),
&mut domain_cch,
&mut kind,
)
};
if ok == 0 {
return Err(crate::safety::bad_state(
builtin,
&format!(
"LookupAccountNameW failed: {}",
std::io::Error::last_os_error()
),
"an account name this machine or its domain knows",
));
}
Ok(sid)
}
/// Set the owner (or the primary group) of one file to `sid` through
/// SetNamedSecurityInfoW. Only the requested part of the security descriptor
/// changes; the DACL and SACL are passed as null and left alone.
#[cfg(windows)]
fn windows_set_file_principal(
builtin: &str,
path: &str,
sid: &mut [u32],
group: bool,
) -> Result<()> {
use std::ffi::c_void;
#[link(name = "advapi32")]
extern "system" {
fn SetNamedSecurityInfoW(
object: *mut u16,
object_type: i32,
info: u32,
owner: *mut c_void,
primary_group: *mut c_void,
dacl: *mut c_void,
sacl: *mut c_void,
) -> u32;
}
const SE_FILE_OBJECT: i32 = 1;
const OWNER_SECURITY_INFORMATION: u32 = 0x0000_0001;
const GROUP_SECURITY_INFORMATION: u32 = 0x0000_0002;
const ERROR_FILE_NOT_FOUND: u32 = 2;
const ERROR_PATH_NOT_FOUND: u32 = 3;
const ERROR_ACCESS_DENIED: u32 = 5;
const ERROR_INVALID_OWNER: u32 = 1307;
const ERROR_PRIVILEGE_NOT_HELD: u32 = 1314;
let mut object: Vec<u16> = path.encode_utf16().chain(std::iter::once(0)).collect();
let (info, owner, primary_group): (u32, *mut c_void, *mut c_void) = if group {
(
GROUP_SECURITY_INFORMATION,
std::ptr::null_mut(),
sid.as_mut_ptr().cast(),
)
} else {
(
OWNER_SECURITY_INFORMATION,
sid.as_mut_ptr().cast(),
std::ptr::null_mut(),
)
};
// SAFETY: `object` is NUL-terminated and outlives the call; `sid` is a
// SID the lookup just filled, and exactly one of owner/primary_group is
// non-null, matching `info`.
let status = unsafe {
SetNamedSecurityInfoW(
object.as_mut_ptr(),
SE_FILE_OBJECT,
info,
owner,
primary_group,
std::ptr::null_mut(),
std::ptr::null_mut(),
)
};
let what = if group { "group" } else { "owner" };
match status {
0 => Ok(()),
ERROR_FILE_NOT_FOUND | ERROR_PATH_NOT_FOUND => {
Err(crate::safety::not_found(builtin, "file", path))
}
ERROR_ACCESS_DENIED | ERROR_PRIVILEGE_NOT_HELD | ERROR_INVALID_OWNER => {
Err(crate::safety::os_denied(
builtin,
format!(
"{builtin}: the operating system refused to set the {what} of '{path}' (Win32 error {status})"
),
"setting yourself as owner needs no privilege; setting another account as owner needs SeRestorePrivilege, and any change needs WRITE_OWNER on the file",
))
}
other => Err(crate::safety::bad_state(
builtin,
&format!("SetNamedSecurityInfoW failed with Win32 error {other}"),
"a file this process can name and change",
)),
}
}
#[cfg(all(test, windows))]
mod win_chown_tests {
use super::*;
/// A temp file with a name this run makes up; the caller removes it.
fn temp_file(tag: &str) -> String {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let path =
std::env::temp_dir().join(format!("ae-{tag}-{}-{nanos}.txt", std::process::id()));
std::fs::write(&path, "x").expect("create temp file");
path.to_string_lossy().into_owned()
}
/// `MACHINE\user`, which LookupAccountNameW resolves on this machine.
fn current_account() -> String {
let user = std::env::var("USERNAME").expect("USERNAME is set on Windows");
match std::env::var("COMPUTERNAME") {
Ok(machine) if !machine.is_empty() => format!("{machine}\\{user}"),
_ => user,
}
}
#[test]
fn fs_chown_sets_the_owner_to_the_current_account() {
// The workspace jail and the mode are process-wide, and other tests in
// this binary set them; without the shared lock a jail set elsewhere
// refuses these temp files.
let _env = crate::safety::ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let path = temp_file("chown-self");
let result = bi_fs_chown(
vec![Value::Str(path.clone()), Value::Str(current_account())],
None,
);
let _ = std::fs::remove_file(&path);
assert_eq!(
result.expect("naming yourself as owner needs no privilege"),
Value::Bool(true)
);
}
#[test]
fn fs_chown_refuses_a_numeric_owner_as_bad_arg() {
// The workspace jail and the mode are process-wide, and other tests in
// this binary set them; without the shared lock a jail set elsewhere
// refuses these temp files.
let _env = crate::safety::ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let path = temp_file("chown-num");
let result = bi_fs_chown(vec![Value::Str(path.clone()), Value::Int(0)], None);
let _ = std::fs::remove_file(&path);
let err = result.expect_err("a numeric owner has no Windows meaning");
assert!(err.to_string().contains("E_BAD_ARG"), "{err}");
}
#[test]
fn fs_chown_reports_an_unknown_account_as_not_found() {
// The workspace jail and the mode are process-wide, and other tests in
// this binary set them; without the shared lock a jail set elsewhere
// refuses these temp files.
let _env = crate::safety::ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let path = temp_file("chown-unknown");
let result = bi_fs_chown(
vec![
Value::Str(path.clone()),
Value::Str("ae-no-such-account-zz".to_string()),
],
None,
);
let _ = std::fs::remove_file(&path);
let err = result.expect_err("no such account exists");
assert!(err.to_string().contains("E_NOT_FOUND"), "{err}");
}
}
fn bi_fs_link(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
if args.len() != 2 || input.is_some() {
return Err(crate::safety::arg_err(
"fs_link requires exactly source and destination paths, without pipeline input",
));
}
let mut args = args.into_iter();
let Some(Value::Str(source)) = args.next() else {
return Err(crate::safety::arg_err("fs_link source must be String"));
};
let Some(Value::Str(destination)) = args.next() else {
return Err(crate::safety::arg_err("fs_link destination must be String"));
};
let source = crate::safety::resolve_path_str(&source);
let destination = crate::safety::resolve_path_str(&destination);
// A hard link exposes the same underlying file through another writable
// name. Guard both paths; outside sources can instead be imported by copy.
crate::safety::guard(crate::safety::GuardCtx {
builtin: "fs_link",
effect: crate::safety::Effect::WriteLocal,
what: "create hard link",
targets: vec![source.clone(), destination.clone()],
blast_radius: serde_json::json!({"paths":2}),
reversible: true,
fs_paths: true,
})?;
let metadata = fs::symlink_metadata(&source)
.map_err(|e| crate::safety::fs_error("fs_link", &source, &e))?;
if !metadata.is_file() {
return Err(crate::safety::arg_err(
"fs_link source must be a regular file, without a symlink",
));
}
match fs::symlink_metadata(&destination) {
Ok(_) => {
return Err(crate::safety::arg_err(
"fs_link destination must not already exist",
))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(crate::safety::fs_error("fs_link", &destination, &e)),
}
crate::tx::snapshot(&destination);
fs::hard_link(&source, &destination)
.map_err(|e| crate::safety::fs_error("fs_link", &destination, &e))?;
Ok(Value::Bool(true))
}
fn bi_fs_symlink(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
if args.len() != 2 || input.is_some() {
return Err(crate::safety::arg_err(
"fs_symlink requires exactly target and destination strings, without pipeline input",
));
}
let mut args = args.into_iter();
let Some(Value::Str(target)) = args.next() else {
return Err(crate::safety::arg_err("fs_symlink target must be String"));
};
let Some(Value::Str(destination)) = args.next() else {
return Err(crate::safety::arg_err(
"fs_symlink destination must be String",
));
};
if target.is_empty() || destination.is_empty() {
return Err(crate::safety::arg_err("fs_symlink paths must not be empty"));
}
#[cfg(windows)]
if !std::path::Path::new(&target).is_absolute()
&& (std::path::Path::new(&target).has_root()
|| matches!(
std::path::Path::new(&target).components().next(),
Some(std::path::Component::Prefix(_))
))
{
return Err(crate::safety::arg_err(
"fs_symlink Windows target must be fully absolute or relative without a drive prefix",
));
}
let destination = crate::safety::resolve_path_str(&destination);
// Preserve the stored target: relative links refer to the destination's
// parent, rather than the process working directory.
let referred = if std::path::Path::new(&target).is_absolute() {
std::path::PathBuf::from(&target)
} else {
std::path::Path::new(&destination)
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join(&target)
};
// Canonical existing targets also expose audit artifacts through aliases.
// Missing targets remain useful dangling links on Unix.
let referred = match fs::canonicalize(&referred) {
Ok(path) => path,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => referred,
Err(e) => return Err(crate::safety::fs_error("fs_symlink", &target, &e)),
};
let referred = referred
.into_os_string()
.into_string()
.map_err(|_| crate::safety::arg_err("fs_symlink resolved target must be valid UTF-8"))?;
crate::safety::guard(crate::safety::GuardCtx {
builtin: "fs_symlink",
effect: crate::safety::Effect::WriteLocal,
what: "create symbolic link",
targets: vec![referred.clone(), destination.clone()],
blast_radius: serde_json::json!({"paths":2}),
reversible: false,
fs_paths: true,
})?;
match fs::symlink_metadata(&destination) {
Ok(_) => {
return Err(crate::safety::arg_err(
"fs_symlink destination must not already exist",
))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(crate::safety::fs_error("fs_symlink", &destination, &e)),
}
crate::tx::snapshot(&destination);
#[cfg(unix)]
std::os::unix::fs::symlink(&target, &destination)
.map_err(|e| crate::safety::fs_error("fs_symlink", &destination, &e))?;
#[cfg(windows)]
{
// Windows requires a link kind. Refuse a missing target instead of
// guessing file and creating a broken directory link.
let metadata = fs::metadata(&referred)
.map_err(|e| crate::safety::fs_error("fs_symlink", &referred, &e))?;
let result = if metadata.is_dir() {
std::os::windows::fs::symlink_dir(&target, &destination)
} else {
std::os::windows::fs::symlink_file(&target, &destination)
};
result.map_err(|e| crate::safety::fs_error("fs_symlink", &destination, &e))?;
}
#[cfg(not(any(unix, windows)))]
return Err(crate::safety::unimplemented(
"fs_symlink",
"symbolic links are unsupported on this platform",
"use file_copy",
));
#[cfg(any(unix, windows))]
Ok(Value::Bool(true))
}
fn bi_fs_readlink(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"fs_readlink",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
// A missing path and a path that is not a symlink were both null.
let target =
std::fs::read_link(&path).map_err(|e| crate::safety::fs_error("fs_readlink", &path, &e))?;
Ok(Value::Str(target.to_string_lossy().to_string()))
}
fn bi_fs_realpath(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"fs_realpath",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
// Only a missing path is Null; the old code made every failure Null.
match std::fs::canonicalize(&path) {
Ok(real) => Ok(Value::Str(real.to_string_lossy().to_string())),
Err(e) if path_is_absent(&e) => Ok(Value::Null),
Err(e) => Err(crate::safety::fs_error("fs_realpath", &path, &e)),
}
}
fn bi_fs_tempfile(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let prefix = args
.first()
.map(|v| expect_string("fs_tempfile", v))
.transpose()?
.unwrap_or("ironshell_");
if prefix.contains(['/', '\\', ':']) {
return Err(crate::safety::arg_err(
"fs_tempfile: prefix must not contain path separators",
));
}
let temp_dir = std::env::temp_dir();
let filename = format!("{}_{}", prefix, uuid::Uuid::new_v4());
let path = temp_dir.join(&filename);
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.map_err(|e| crate::safety::fs_error("fs_tempfile", &path, &e))?;
Ok(Value::Str(path.to_string_lossy().to_string()))
}
fn bi_fs_tempdir(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let prefix = args
.first()
.map(|v| expect_string("fs_tempdir", v))
.transpose()?
.unwrap_or("ironshell_");
if prefix.contains(['/', '\\', ':']) {
return Err(crate::safety::arg_err(
"fs_tempdir: prefix must not contain path separators",
));
}
let temp_dir = std::env::temp_dir();
let dirname = format!("{}_{}", prefix, uuid::Uuid::new_v4());
let path = temp_dir.join(&dirname);
// A failure was null, which reads as "no directory" rather than "could not".
std::fs::create_dir(&path).map_err(|e| crate::safety::fs_error("fs_tempdir", &path, &e))?;
Ok(Value::Str(path.to_string_lossy().to_string()))
}
#[path = "fs_watch.rs"]
mod fs_watch;
fn bi_fs_watch(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
fs_watch::watch(args, input)
}
fn bi_fs_unwatch(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
fs_watch::unwatch(args, input)
}
/// The directory a glob's literal prefix names: everything before the first
/// component with a metacharacter. Empty means the working directory.
fn glob_literal_dir(pattern: &str) -> String {
let mut parts = Vec::new();
for c in pattern.split('/') {
if crate::posix::has_glob_meta(c) {
break;
}
parts.push(c);
}
if parts.len() == pattern.split('/').count() {
// No metacharacter at all: the pattern is a path; its parent must exist.
parts.pop();
}
let dir = parts.join("/");
if dir.is_empty() && pattern.starts_with('/') {
"/".to_string()
} else {
dir
}
}
/// Expand a glob for a typed builtin: `**` is recursive, results sort by
/// bytes, and a literal directory prefix that does not exist is E_NOT_FOUND
/// rather than an empty answer that reads like "no files".
fn expand_glob_for(builtin: &str, pattern: &str) -> Result<Vec<String>> {
crate::security::check_share_egress(builtin, pattern)?;
// Windows callers may write either separator; globbing is done on `/`.
let pattern = pattern.replace(char::from(92), "/");
let dir = glob_literal_dir(&pattern);
if !dir.is_empty() && !std::path::Path::new(&dir).is_dir() {
return Err(crate::safety::not_found(builtin, "directory", &dir));
}
// The cap is applied while gathering, so an over-large glob stops early
// and is refused, not built in full and counted afterwards.
let (paths, over) = crate::posix::glob_paths_capped(&pattern, WALK_MAX_ENTRIES);
if over {
return Err(walk_over(builtin, WALK_MAX_ENTRIES));
}
Ok(paths)
}
fn bi_fs_glob(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// This matched `*.rs` against any name *containing* ".rs" -- `main.rsx`
// and `x.rs.bak` included -- had no `**`, returned directory order, and
// answered [] for a directory that does not exist. `glob("src/**/*.rs")`
// said 0.
let pattern = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"fs_glob",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
Ok(Value::Array(
expand_glob_for("glob", &pattern)?
.into_iter()
.map(Value::Str)
.collect(),
))
}
/// lines(text): split text into lines, without their terminators; a final
/// newline does not add an empty line, and a CR before LF is dropped.
fn bi_lines(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let text = match (input, args.first()) {
(Some(Value::Str(s)), _) => s,
(None, Some(Value::Str(s))) => s.clone(),
(Some(other), _) => {
return Err(crate::safety::bad_arg("lines", "String", other.type_name()))
}
(None, Some(other)) => {
return Err(crate::safety::bad_arg("lines", "String", other.type_name()))
}
(None, None) => return Err(crate::safety::bad_arg("lines", "String", "nothing")),
};
let cr = char::from(13);
let nl = char::from(10);
let mut out: Vec<Value> = text
.split(nl)
.map(|l| Value::Str(l.strip_suffix(cr).unwrap_or(l).to_string()))
.collect();
if text.is_empty() || text.ends_with(nl) {
out.pop();
}
Ok(Value::Array(out))
}
/// open(path): read a file and parse it by its extension -- .json, .jsonl,
/// .toml, .yaml/.yml, .csv -- or return its text. Borrowed from nushell,
/// where `open Cargo.toml | get package.version` is the whole task.
fn bi_open(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"open",
"path: String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
if !std::path::Path::new(&path).is_file() {
return Err(crate::safety::not_found("open", "file", &path));
}
let validated = validate_read_path(&path)?;
let md =
fs::metadata(&validated).map_err(|e| crate::safety::fs_error("open", &validated, &e))?;
check_file_size_limit(md.len()).context("open: file too large")?;
let text = fs::read_to_string(&validated)
.map_err(|e| crate::safety::fs_error("open", &validated, &e))?;
let ext = std::path::Path::new(&path)
.extension()
.map(|e| e.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
let bad = |kind: &str, e: String| {
crate::safety::bad_arg("open", &format!("valid {kind} in {path}"), &e)
};
match ext.as_str() {
"json" => {
let j: serde_json::Value =
serde_json::from_str(&text).map_err(|e| bad("JSON", e.to_string()))?;
Ok(Value::from_json(&j))
}
"jsonl" | "ndjson" => {
let mut rows = Vec::new();
for line in text.lines().filter(|l| !l.trim().is_empty()) {
let j: serde_json::Value =
serde_json::from_str(line).map_err(|e| bad("JSON lines", e.to_string()))?;
rows.push(Value::from_json(&j));
}
Ok(Value::Array(rows))
}
"toml" => {
let t: toml::Value = toml::from_str(&text).map_err(|e| bad("TOML", e.to_string()))?;
Ok(crate::plugins::toml_to_value(t))
}
"yaml" | "yml" => crate::yaml::parse(&text).map_err(|e| bad("YAML", e.to_string())),
"csv" => bi_from_csv(vec![], Some(Value::Str(text))),
_ => Ok(Value::Str(text)),
}
}
/// Whether `path` is a directory. `is_dir()` answers false when it cannot
/// stat the entry, so a directory it could not read looked like a file and
/// was skipped. A dangling symlink, or an entry removed since it was listed,
/// is `false`; any other failure to stat it is an error.
fn entry_is_dir(builtin: &str, path: &std::path::Path) -> Result<bool> {
match std::fs::metadata(path) {
Ok(meta) => Ok(meta.is_dir()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(crate::safety::fs_error(builtin, path, &e)),
}
}
/// Whether a failed stat means the path does not exist. A component that is a
/// regular file (`plain/x`) is ENOTDIR, which `test -e` also reads as absent.
fn path_is_absent(e: &std::io::Error) -> bool {
matches!(
e.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
)
}
/// Whether `path` is a regular file. Same rule as `entry_is_dir`: only a
/// missing entry answers `false`; any other failure to stat it is an error.
fn entry_is_file(builtin: &str, path: &std::path::Path) -> Result<bool> {
match std::fs::metadata(path) {
Ok(meta) => Ok(meta.is_file()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(crate::safety::fs_error(builtin, path, &e)),
}
}
/// Reads a project text file. `Ok(None)` only when the file does not exist;
/// any other failure is an error, and so is a file over the read limit.
/// Non-UTF-8 bytes are replaced rather than treated as absence, so a Latin-1
/// README still returns its text.
fn read_text_lossy(builtin: &str, path: &std::path::Path) -> Result<Option<String>> {
// The capped reader codes its errors, so the io kind is gone by the time
// it fails; a path under a regular file (NotADirectory) is absent, as
// `path_is_absent` says, and is told apart here first.
if let Err(e) = std::fs::metadata(path) {
if path_is_absent(&e) {
return Ok(None);
}
}
match read_bytes_capped(builtin, path) {
Ok(bytes) => Ok(Some(String::from_utf8_lossy(&bytes).into_owned())),
Err(e) if capped_read_not_found(&e) => Ok(None),
Err(e) => Err(e),
}
}
/// The first of `names` that exists in the working directory, as text, or
/// Null when none of them exists.
fn first_project_text(builtin: &str, names: &[&str]) -> Result<Value> {
for name in names {
if let Some(text) = read_text_lossy(builtin, std::path::Path::new(name))? {
return Ok(Value::Str(text));
}
}
Ok(Value::Null)
}
/// The entries under `dir` down to `max_depth`, depth first, refused past `max`.
///
/// A directory that cannot be listed is an error, not an empty subtree. The
/// walk used to skip it, and an unreadable root returned []: a shorter listing
/// that looked complete.
fn walk_entries_capped(dir: &std::path::Path, max_depth: usize, max: usize) -> Result<Vec<Value>> {
fn walk_recursive(
dir: &std::path::Path,
depth: usize,
max_depth: usize,
max: usize,
results: &mut Vec<Value>,
) -> Result<()> {
if depth > max_depth {
return Ok(());
}
let entries =
std::fs::read_dir(dir).map_err(|e| crate::safety::fs_error("fs_walk", dir, &e))?;
for entry in entries {
let entry = entry.map_err(|e| crate::safety::fs_error("fs_walk", dir, &e))?;
let path = entry.path();
let shown = Value::Str(path.to_string_lossy().to_string());
push_capped("fs_walk", results, shown, max)?;
// Only a directory the walk would descend into needs its type
// checked; one at the depth limit is listed and not opened.
if depth < max_depth && entry_is_dir("fs_walk", &path)? {
walk_recursive(&path, depth + 1, max_depth, max, results)?;
}
}
Ok(())
}
let mut results = Vec::new();
walk_recursive(dir, 0, max_depth, max, &mut results)?;
Ok(results)
}
fn bi_fs_walk(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let dir = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => ".".to_string(),
};
let max_depth = args
.get(1)
.and_then(|v| match v {
Value::Int(d) => Some(*d as usize),
_ => None,
})
.unwrap_or(100);
let results = walk_entries_capped(std::path::Path::new(&dir), max_depth, WALK_MAX_ENTRIES)?;
Ok(Value::Array(results))
}
fn bi_fs_tree(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let dir = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => ".".to_string(),
};
let max_depth = args
.get(1)
.and_then(|v| match v {
Value::Int(d) => Some(*d as usize),
_ => None,
})
.unwrap_or(3);
// An unreadable directory used to render as a leaf, and an entry that
// could not be read was left out of its parent's listing. Both are
// errors now: the tree would be a shorter one presented as the whole.
fn tree_recursive(
dir: &std::path::Path,
prefix: &str,
depth: usize,
max_depth: usize,
max: usize,
lines: &mut Vec<String>,
) -> Result<()> {
if depth > max_depth {
return Ok(());
}
let entries: Vec<_> = std::fs::read_dir(dir)
.map_err(|e| crate::safety::fs_error("fs_tree", dir, &e))?
.collect::<std::io::Result<Vec<_>>>()
.map_err(|e| crate::safety::fs_error("fs_tree", dir, &e))?;
let count = entries.len();
for (i, entry) in entries.into_iter().enumerate() {
let is_last = i == count - 1;
let connector = if is_last { "└── " } else { "├── " };
let name = entry.file_name().to_string_lossy().to_string();
// `lines` starts with the root's own line, so the nodes so far are one fewer.
check_walk_room("fs_tree", lines.len() - 1, max)?;
lines.push(format!("{}{}{}", prefix, connector, name));
let path = entry.path();
if depth < max_depth && entry_is_dir("fs_tree", &path)? {
let new_prefix = format!("{}{}", prefix, if is_last { " " } else { "│ " });
tree_recursive(&path, &new_prefix, depth + 1, max_depth, max, lines)?;
}
}
Ok(())
}
let mut lines = vec![dir.clone()];
tree_recursive(
std::path::Path::new(&dir),
"",
0,
max_depth,
WALK_MAX_ENTRIES,
&mut lines,
)?;
Ok(Value::Str(lines.join("\n")))
}
fn bi_fs_du(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let path = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => ".".to_string(),
};
// A total that leaves out an entry it could not stat or list is wrong, so
// those are errors. The old code counted such an entry as 0 bytes, and an
// unreadable or missing root as an empty directory.
fn dir_size(path: &std::path::Path) -> Result<u64> {
let meta =
std::fs::metadata(path).map_err(|e| crate::safety::fs_error("fs_du", path, &e))?;
if meta.is_file() {
return Ok(meta.len());
}
let mut size = 0;
let entries =
std::fs::read_dir(path).map_err(|e| crate::safety::fs_error("fs_du", path, &e))?;
for entry in entries {
let entry_path = entry
.map_err(|e| crate::safety::fs_error("fs_du", path, &e))?
.path();
match std::fs::metadata(&entry_path) {
Ok(m) if m.is_file() => size += m.len(),
Ok(m) if m.is_dir() => size += dir_size(&entry_path)?,
Ok(_) => {}
// A dangling symlink, or an entry removed since it was listed.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(crate::safety::fs_error("fs_du", &entry_path, &e)),
}
}
Ok(size)
}
let size = dir_size(std::path::Path::new(&path))?;
let mut rec = std::collections::BTreeMap::new();
rec.insert("path".to_string(), Value::Str(path));
rec.insert("bytes".to_string(), Value::Int(size as i64));
rec.insert("kb".to_string(), Value::Float(size as f64 / 1024.0));
rec.insert(
"mb".to_string(),
Value::Float(size as f64 / 1024.0 / 1024.0),
);
rec.insert(
"gb".to_string(),
Value::Float(size as f64 / 1024.0 / 1024.0 / 1024.0),
);
Ok(Value::Record(rec))
}
fn bi_fs_df(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// This was `Get-PSDrive -PSProvider FileSystem | Select-Object Name,Used,Free`
// through PowerShell. It now reads the same drives from kernel32 (fixed,
// removable, network and RAM-disk roots). `size` is the true total from
// GetDiskFreeSpaceExW; it was used + free before, which is the same number.
// `used` is total minus free, and `available` is the free figure. A drive
// whose sizes cannot be read keeps `filesystem` and `mounted_on`, and the
// size fields are left out.
#[cfg(target_os = "windows")]
{
let mut drives = Vec::new();
for v in windows_volumes("fs_df")?
.into_iter()
.filter(|v| windows_listed_by_powershell(v.drive_type))
{
let mut rec = std::collections::BTreeMap::new();
rec.insert(
"filesystem".to_string(),
Value::Str(format!("{}:\\", v.letter)),
);
if let (Some(total), Some(free)) = (v.total, v.free) {
let used = total.saturating_sub(free);
let use_pct = if total > 0 {
(used as f64 / total as f64 * 100.0) as i64
} else {
0
};
rec.insert("size".to_string(), Value::Str(format_bytes(total as i64)));
rec.insert("used".to_string(), Value::Str(format_bytes(used as i64)));
rec.insert(
"available".to_string(),
Value::Str(format_bytes(free as i64)),
);
rec.insert(
"use_percent".to_string(),
Value::Str(format!("{}%", use_pct)),
);
}
rec.insert(
"mounted_on".to_string(),
Value::Str(format!("{}:\\", v.letter)),
);
drives.push(Value::Record(rec));
}
return Ok(Value::Array(drives));
}
#[cfg(not(target_os = "windows"))]
{
// df -h works on both Linux and macOS
let mut cmd = crate::safety::child_command("df");
cmd.args(["-h"]);
let output = bounded_cmd_output("fs_df", "df", cmd, PROBE_DEADLINE_SECS)?;
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout);
let mut drives = Vec::new();
for line in text.lines().skip(1) {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 6 {
let mut rec = std::collections::BTreeMap::new();
rec.insert("filesystem".to_string(), Value::Str(parts[0].to_string()));
rec.insert("size".to_string(), Value::Str(parts[1].to_string()));
rec.insert("used".to_string(), Value::Str(parts[2].to_string()));
rec.insert("available".to_string(), Value::Str(parts[3].to_string()));
rec.insert("use_percent".to_string(), Value::Str(parts[4].to_string()));
rec.insert("mounted_on".to_string(), Value::Str(parts[5].to_string()));
drives.push(Value::Record(rec));
}
}
return Ok(Value::Array(drives));
}
}
#[cfg(not(target_os = "windows"))]
{
Ok(Value::Array(vec![]))
}
}
fn bi_fs_mount(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
acl_mount::fs_mount(args, input)
}
fn bi_fs_unmount(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
acl_mount::fs_unmount(args, input)
}
fn bi_fs_mounts(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// This was `Get-PSDrive -PSProvider FileSystem | Select-Object Name,Root`
// through PowerShell, with fstype hardcoded to "NTFS". It now lists the
// same drives as fs_df (fixed, removable, network and RAM-disk roots) from
// kernel32. `fstype` is the volume's real file system name from
// GetVolumeInformationW, and it is left out when that call fails or the
// volume has no name.
#[cfg(target_os = "windows")]
{
let mut result = Vec::new();
for v in windows_volumes("fs_mounts")?
.into_iter()
.filter(|v| windows_listed_by_powershell(v.drive_type))
{
let mut rec = std::collections::BTreeMap::new();
rec.insert("device".to_string(), Value::Str(format!("{}:", v.letter)));
rec.insert(
"mountpoint".to_string(),
Value::Str(format!("{}:\\", v.letter)),
);
if let Some(fs) = v.fs_name.filter(|s| !s.is_empty()) {
rec.insert("fstype".to_string(), Value::Str(fs));
}
rec.insert("options".to_string(), Value::Str("rw".to_string()));
result.push(Value::Record(rec));
}
return Ok(Value::Array(result));
}
#[cfg(target_os = "linux")]
{
if let Ok(mounts) = std::fs::read_to_string("/proc/mounts") {
let mut result = Vec::new();
for line in mounts.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 4 {
let mut rec = std::collections::BTreeMap::new();
rec.insert("device".to_string(), Value::Str(parts[0].to_string()));
rec.insert("mountpoint".to_string(), Value::Str(parts[1].to_string()));
rec.insert("fstype".to_string(), Value::Str(parts[2].to_string()));
rec.insert("options".to_string(), Value::Str(parts[3].to_string()));
result.push(Value::Record(rec));
}
}
return Ok(Value::Array(result));
}
}
#[cfg(target_os = "macos")]
{
// macOS: use mount command (no /proc/mounts)
let cmd = crate::safety::child_command("mount");
let output = bounded_cmd_output("fs_mounts", "mount", cmd, PROBE_DEADLINE_SECS)?;
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout);
let mut result = Vec::new();
for line in text.lines() {
// Format: /dev/disk1s1 on / (apfs, local, journaled)
if let Some((device_part, rest)) = line.split_once(" on ") {
let mut rec = std::collections::BTreeMap::new();
rec.insert("device".to_string(), Value::Str(device_part.to_string()));
if let Some((mountpoint, opts)) = rest.split_once(" (") {
rec.insert("mountpoint".to_string(), Value::Str(mountpoint.to_string()));
let opts_clean = opts.trim_end_matches(')');
let parts: Vec<&str> = opts_clean.split(", ").collect();
rec.insert(
"fstype".to_string(),
Value::Str(parts.first().unwrap_or(&"").to_string()),
);
rec.insert("options".to_string(), Value::Str(parts[1..].join(",")));
} else {
rec.insert("mountpoint".to_string(), Value::Str(rest.to_string()));
rec.insert("fstype".to_string(), Value::Str("unknown".to_string()));
rec.insert("options".to_string(), Value::Str(String::new()));
}
result.push(Value::Record(rec));
}
}
return Ok(Value::Array(result));
}
}
// A failed query answered [], "nothing mounted".
#[cfg(not(target_os = "windows"))]
{
Err(crate::safety::tool_failed(
"fs_mounts",
"the mount table",
None,
"it could not be read",
))
}
}
// ============================================================================
// 270-289: Network Operations
//
// Every builtin here reads a host tool or the kernel's tables and answers with
// a typed value, or refuses with a code. Nothing runs through a shell: each
// tool gets an argument vector, and every host argument is checked to be a
// hostname or address literal that cannot be read as an option.
// ============================================================================
/// Records one net_* table may return. A longer table is refused, not cut.
const NET_MAX_RECORDS: usize = 4096;
/// Bytes of whois text kept in `raw`; a longer answer is cut and flagged in
/// `raw_truncated`, because the fields it was parsed for are near the top.
const WHOIS_RAW_MAX: usize = 64 * 1024;
/// Bytes of `netstat -s` text returned; a longer answer is refused.
const NET_STATS_MAX: usize = 1024 * 1024;
/// Ports one net_scan call may probe.
const NET_SCAN_MAX_PORTS: i64 = 1024;
/// Wall-clock bound on one whole net_scan call.
const NET_SCAN_DEADLINE_SECS: u64 = 30;
/// Bound on any single host-tool run that is not otherwise sized.
const NET_TOOL_SECS: u64 = 30;
type NetRecord = BTreeMap<String, Value>;
/// An Int argument at `idx`: `default` when absent (and given), otherwise
/// required. Anything outside `lo..=hi` is refused, never clamped.
fn net_int(
name: &str,
args: &[Value],
idx: usize,
default: Option<i64>,
lo: i64,
hi: i64,
) -> Result<i64> {
let n = match args.get(idx) {
None => match default {
Some(d) => return Ok(d),
None => {
return Err(crate::safety::bad_arg(
name,
"a required Int argument",
"nothing",
))
}
},
Some(Value::Int(n)) => *n,
Some(other) => return Err(crate::safety::bad_arg(name, "an Int", other.type_name())),
};
if !(lo..=hi).contains(&n) {
return Err(crate::safety::bad_arg(
name,
&format!("an Int in {lo}..={hi}"),
&n.to_string(),
));
}
Ok(n)
}
/// A hostname or IP address argument at `idx`. Option-like text (a leading
/// `-` or `+`) is refused before anything else, and the rest must pass the
/// hostname validator, so no host can reach a tool as a flag.
fn net_host(name: &str, args: &[Value], idx: usize) -> Result<String> {
let Some(value) = args.get(idx) else {
return Err(crate::safety::bad_arg(
name,
"a hostname or IP address String",
"nothing",
));
};
let Value::Str(host) = value else {
return Err(crate::safety::bad_arg(
name,
"a hostname or IP address String",
value.type_name(),
));
};
crate::safety::reject_option_like(name, std::slice::from_ref(host))?;
validate_hostname_or_ip(host).map_err(|e| {
crate::safety::bad_arg(name, "a hostname or IP address", &format!("{host}: {e}"))
})
}
/// An IPv4 or IPv6 literal argument at `idx`. Names are not accepted here, so
/// a reverse lookup cannot be asked for a host it was not given an address for.
fn net_ip_literal(name: &str, args: &[Value], idx: usize) -> Result<std::net::IpAddr> {
let Some(value) = args.get(idx) else {
return Err(crate::safety::bad_arg(
name,
"an IPv4 or IPv6 address String",
"nothing",
));
};
let Value::Str(text) = value else {
return Err(crate::safety::bad_arg(
name,
"an IPv4 or IPv6 address String",
value.type_name(),
));
};
text.parse::<std::net::IpAddr>()
.map_err(|_| crate::safety::bad_arg(name, "an IPv4 or IPv6 address literal", text))
}
/// Refuse a call inside a transaction when the files it writes cannot be named
/// before it runs (an archive's members, say), so the journal cannot snapshot
/// them. The refusal is coded the same way as the scheduler's and the network
/// builtins', so an agent sees why, not a silent unrolled change.
fn refuse_in_transaction(builtin: &str, why: &str) -> Result<()> {
if crate::tx::is_active() {
return Err(crate::safety::unimplemented(
builtin,
why,
"finish the transaction first",
));
}
Ok(())
}
/// Refuse a network-effect call inside a transaction, which cannot roll back
/// traffic that has already left the machine.
fn net_refuse_in_transaction(name: &str) -> Result<()> {
if crate::tx::is_active() {
return Err(crate::safety::unimplemented(
name,
"network traffic cannot be rolled back",
"finish the transaction first",
));
}
Ok(())
}
/// Run a host tool with an argument vector (never through a shell) under a
/// wall-clock bound. A missing tool is E_TOOL_MISSING; no answer in time is a
/// coded budget stop.
fn net_run(name: &str, tool: &str, argv: &[&str], secs: u64) -> Result<std::process::Output> {
let mut cmd = crate::safety::child_command(tool);
cmd.args(argv);
crate::safety::output_with_timeout(cmd, secs).map_err(|e| {
if e.kind() == std::io::ErrorKind::TimedOut {
crate::safety::budget_exceeded(
name,
format!("`{tool}` gave no result within {secs} s"),
"retry with less work, or check the host",
)
} else {
crate::safety::spawn_error(name, tool, &e)
}
})
}
fn tail_chars(text: &str, n: usize) -> String {
let count = text.chars().count();
if count <= n {
text.to_string()
} else {
text.chars().skip(count - n).collect()
}
}
/// What a failed tool said: its stderr, or else its last non-empty stdout line.
fn net_diag(out: &std::process::Output) -> String {
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
let text = if err.is_empty() {
String::from_utf8_lossy(&out.stdout)
.lines()
.rev()
.find(|l| !l.trim().is_empty())
.unwrap_or("")
.trim()
.to_string()
} else {
err
};
tail_chars(&text, 400)
}
/// A nonzero exit is a coded tool failure carrying the tool's own words.
fn net_checked(name: &str, tool: &str, out: &std::process::Output) -> Result<()> {
if out.status.success() {
return Ok(());
}
Err(crate::safety::tool_failed(
name,
tool,
out.status.code(),
&net_diag(out),
))
}
/// The tool ran and exited cleanly, but its output is not in a form this
/// builtin reads. Coded, so the caller learns the answer is unavailable here
/// rather than receiving an empty one.
fn net_unparsed(name: &str, tool: &str) -> anyhow::Error {
anyhow::Error::new(crate::safety::SafetyError {
code: crate::safety::ErrorCode::ToolFailed,
message: format!("{name}: the output of `{tool}` is not in a form this builtin reads"),
builtin: name.to_string(),
hint: format!(
"`{tool}` ran, but this host prints its output in a form this builtin does not parse"
),
approval: None,
did_you_mean: Vec::new(),
expected: format!("`{tool}` output this builtin reads"),
got: "unreadable output".to_string(),
})
}
/// Refuse a table longer than the record bound. A table is never cut silently.
fn net_cap(name: &str, rows: Vec<Value>) -> Result<Value> {
if rows.len() > NET_MAX_RECORDS {
return Err(crate::safety::budget_exceeded(
name,
format!(
"{} records exceed the {NET_MAX_RECORDS}-record limit",
rows.len()
),
"the table is too large to return whole; use a narrower reader",
));
}
Ok(Value::Array(rows))
}
/// The items of a `ConvertTo-Json` result. Empty output is a real empty answer
/// (PowerShell prints nothing for an empty pipeline); other non-JSON output is
/// unreadable and is refused.
#[cfg(windows)]
fn net_ps_items(name: &str, stdout: &[u8]) -> Result<Vec<serde_json::Value>> {
let text = String::from_utf8_lossy(stdout);
let text = text.trim();
if text.is_empty() {
return Ok(Vec::new());
}
match serde_json::from_str::<serde_json::Value>(text) {
Ok(serde_json::Value::Array(items)) => Ok(items),
Ok(obj @ serde_json::Value::Object(_)) => Ok(vec![obj]),
_ => Err(net_unparsed(name, "powershell")),
}
}
fn net_float(rec: &Value, key: &str) -> Option<f64> {
match rec {
Value::Record(r) => match r.get(key) {
Some(Value::Float(f)) => Some(*f),
_ => None,
},
_ => None,
}
}
fn net_has(rec: &Value, key: &str) -> bool {
matches!(rec, Value::Record(r) if r.contains_key(key))
}
/// A string field the tool must report. A missing key or a non-string value
/// is refused: an identity field read as "" would make the record lie.
#[cfg(any(windows, target_os = "linux", test))]
fn net_req_str(
name: &str,
tool: &str,
obj: &serde_json::Map<String, serde_json::Value>,
key: &str,
) -> Result<String> {
obj.get(key)
.and_then(|v| v.as_str())
.map(str::to_string)
.ok_or_else(|| net_unparsed(name, tool))
}
/// A string field that may legitimately be absent or empty. When the tool
/// leaves it out, the field is left out of the record too; it is not "".
#[cfg(any(windows, target_os = "linux", test))]
fn net_opt_field(
out_key: &'static str,
obj: &serde_json::Map<String, serde_json::Value>,
json_key: &str,
) -> Option<(&'static str, Value)> {
obj.get(json_key)
.and_then(|v| v.as_str())
.map(|s| (out_key, Value::Str(s.to_string())))
}
fn net_record(pairs: Vec<(&str, Value)>) -> Value {
let mut rec = NetRecord::new();
for (key, value) in pairs {
rec.insert(key.to_string(), value);
}
Value::Record(rec)
}
fn bi_net_interfaces(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "net_interfaces";
no_args_or_input(NAME, &args, &input)?;
net_cap(NAME, net_interface_rows(NAME)?)
}
#[cfg(windows)]
fn net_interface_rows(name: &str) -> Result<Vec<Value>> {
let script = "Get-NetAdapter | Select-Object Name,InterfaceDescription,MacAddress,Status,LinkSpeed | ConvertTo-Json";
let out = net_run(
name,
"powershell",
&["-NoProfile", "-NonInteractive", "-Command", script],
NET_TOOL_SECS,
)?;
net_checked(name, "powershell", &out)?;
let mut rows = Vec::new();
for item in net_ps_items(name, &out.stdout)? {
let Some(o) = item.as_object() else {
return Err(net_unparsed(name, "powershell"));
};
rows.push(net_interface_record(name, o)?);
}
Ok(rows)
}
/// One adapter record from `Get-NetAdapter`. The name is required. The
/// description, MAC, status and speed are left out when the adapter does not
/// report them: a virtual adapter has an empty MacAddress.
#[cfg(any(windows, test))]
fn net_interface_record(
name: &str,
o: &serde_json::Map<String, serde_json::Value>,
) -> Result<Value> {
let mut pairs = vec![(
"name",
Value::Str(net_req_str(name, "powershell", o, "Name")?),
)];
pairs.extend(net_opt_field("description", o, "InterfaceDescription"));
pairs.extend(net_opt_field("mac", o, "MacAddress"));
pairs.extend(net_opt_field("status", o, "Status"));
pairs.extend(net_opt_field("speed", o, "LinkSpeed"));
Ok(net_record(pairs))
}
#[cfg(target_os = "linux")]
fn net_interface_rows(name: &str) -> Result<Vec<Value>> {
let out = net_run(name, "ip", &["-j", "addr", "show"], NET_TOOL_SECS)?;
net_checked(name, "ip", &out)?;
let json: serde_json::Value =
serde_json::from_slice(&out.stdout).map_err(|_| net_unparsed(name, "ip"))?;
let serde_json::Value::Array(links) = json else {
return Err(net_unparsed(name, "ip"));
};
let mut rows = Vec::new();
for link in &links {
let Some(o) = link.as_object() else {
return Err(net_unparsed(name, "ip"));
};
let addresses: Vec<Value> = o
.get("addr_info")
.and_then(|v| v.as_array())
.map(|infos| {
infos
.iter()
.filter_map(|i| i.get("local").and_then(|v| v.as_str()))
.map(|s| Value::Str(s.to_string()))
.collect()
})
.unwrap_or_default();
let mut pairs = vec![("name", Value::Str(net_req_str(name, "ip", o, "ifname")?))];
// `address` is absent on some tunnel links; the field is then left out.
pairs.extend(net_opt_field("mac", o, "address"));
pairs.extend(net_opt_field("status", o, "operstate"));
// A link that reports no mtu gets no `mtu` field. It used to read as
// an mtu of 0, which no interface has.
if let Some(mtu) = o.get("mtu").and_then(|v| v.as_i64()) {
pairs.push(("mtu", Value::Int(mtu)));
}
pairs.push(("addresses", Value::Array(addresses)));
rows.push(net_record(pairs));
}
Ok(rows)
}
#[cfg(target_os = "macos")]
fn net_interface_rows(name: &str) -> Result<Vec<Value>> {
let out = net_run(name, "ifconfig", &[], NET_TOOL_SECS)?;
net_checked(name, "ifconfig", &out)?;
let text = String::from_utf8_lossy(&out.stdout);
let mut rows: Vec<NetRecord> = Vec::new();
for line in text.lines() {
if !line.starts_with(['\t', ' ']) && line.contains(':') {
// A header such as `en0: flags=8863<UP,BROADCAST,...> mtu 1500`.
let mut rec = NetRecord::new();
let iface = line.split(':').next().unwrap_or("").to_string();
rec.insert("name".to_string(), Value::Str(iface));
// An interface with no `ether` line (lo0, utun) gets no mac field,
// as on Windows and Linux; it used to read as "".
let status = if line.contains("UP") { "Up" } else { "Down" };
rec.insert("status".to_string(), Value::Str(status.to_string()));
rows.push(rec);
} else if let Some(mac) = line.trim().strip_prefix("ether ") {
if let Some(last) = rows.last_mut() {
last.insert("mac".to_string(), Value::Str(mac.trim().to_string()));
}
}
}
Ok(rows.into_iter().map(Value::Record).collect())
}
#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
fn net_interface_rows(name: &str) -> Result<Vec<Value>> {
Err(crate::safety::unimplemented(
name,
"interfaces are not read on this platform",
"run on Windows, Linux or macOS",
))
}
fn bi_net_ip(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "net_ip";
no_args_or_input(NAME, &args, &input)?;
net_address(NAME)
}
/// One network adapter as GetAdaptersAddresses reports it.
#[cfg(windows)]
struct WinAdapter {
/// The friendly name, `Ethernet` or `Wi-Fi`: Get-NetIPAddress's
/// InterfaceAlias.
name: String,
/// `aa:bb:cc:dd:ee:ff`, or `None` for an adapter with no hardware address
/// (loopback, most VPN tunnels).
mac: Option<String>,
up: bool,
addresses: Vec<std::net::IpAddr>,
}
/// Every adapter with its unicast addresses, from iphlpapi.
///
/// This is the table Get-NetIPAddress and ipconfig read. sysinfo's interface
/// list is not: on one host it left out the loopback interface, a VPN tunnel
/// carrying the host's traffic and every disconnected Wi-Fi adapter.
#[cfg(windows)]
fn windows_adapters(builtin: &str) -> Result<Vec<WinAdapter>> {
use std::ffi::c_void;
// The leading fields of IP_ADAPTER_ADDRESSES_LH, up to OperStatus. The
// buffer is the OS's, so a prefix is all that has to match.
#[repr(C)]
struct AdapterAddresses {
length: u32,
if_index: u32,
next: *const AdapterAddresses,
adapter_name: *const u8,
first_unicast: *const UnicastAddress,
first_anycast: *const c_void,
first_multicast: *const c_void,
first_dns_server: *const c_void,
dns_suffix: *const u16,
description: *const u16,
friendly_name: *const u16,
physical_address: [u8; 8],
physical_address_length: u32,
flags: u32,
mtu: u32,
if_type: u32,
oper_status: u32,
}
// The leading fields of IP_ADAPTER_UNICAST_ADDRESS_LH.
#[repr(C)]
struct UnicastAddress {
length: u32,
flags: u32,
next: *const UnicastAddress,
sockaddr: *const u16,
sockaddr_length: i32,
}
#[link(name = "iphlpapi")]
extern "system" {
fn GetAdaptersAddresses(
family: u32,
flags: u32,
reserved: *mut c_void,
addresses: *mut c_void,
size: *mut u32,
) -> u32;
}
const AF_UNSPEC: u32 = 0;
const AF_INET: u16 = 2;
const AF_INET6: u16 = 23;
const GAA_FLAG_SKIP_ANYCAST: u32 = 0x2;
const GAA_FLAG_SKIP_MULTICAST: u32 = 0x4;
const GAA_FLAG_SKIP_DNS_SERVER: u32 = 0x8;
const ERROR_BUFFER_OVERFLOW: u32 = 111;
const ERROR_NO_DATA: u32 = 232;
const IF_OPER_STATUS_UP: u32 = 1;
// u64 elements, so the buffer is aligned for the structures in it.
let mut buf: Vec<u64> = vec![0; 16 * 1024 / 8];
let status = loop {
let mut size = (buf.len() * 8) as u32;
// SAFETY: `buf` holds `size` writable bytes.
let status = unsafe {
GetAdaptersAddresses(
AF_UNSPEC,
GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_DNS_SERVER,
std::ptr::null_mut(),
buf.as_mut_ptr().cast(),
&mut size,
)
};
if status == ERROR_BUFFER_OVERFLOW && size as usize > buf.len() * 8 {
buf = vec![0; (size as usize).div_ceil(8)];
continue;
}
break status;
};
if status == ERROR_NO_DATA {
return Ok(Vec::new());
}
if status != 0 {
return Err(crate::safety::bad_state(
builtin,
&format!(
"GetAdaptersAddresses failed: {}",
std::io::Error::from_raw_os_error(status as i32)
),
"a readable network adapter table",
));
}
// A NUL-terminated UTF-16 string the OS owns.
let wide = |p: *const u16| {
if p.is_null() {
return String::new();
}
let mut n = 0;
// SAFETY: the OS terminates every string in the table.
while unsafe { *p.add(n) } != 0 {
n += 1;
}
// SAFETY: `n` units were just read.
String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(p, n) })
};
let mut adapters = Vec::new();
let mut node = buf.as_ptr() as *const AdapterAddresses;
while !node.is_null() {
// SAFETY: `node` is the head of, or a `next` link in, the list the OS
// wrote into `buf`, which outlives this loop.
let adapter = unsafe { &*node };
let mac_len = (adapter.physical_address_length as usize).min(8);
let mac = (mac_len > 0).then(|| {
adapter.physical_address[..mac_len]
.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(":")
});
let mut addresses = Vec::new();
let mut unicast = adapter.first_unicast;
while !unicast.is_null() {
// SAFETY: a link in the same OS-written list.
let u = unsafe { &*unicast };
if !u.sockaddr.is_null() {
// SAFETY: a SOCKADDR starts with its u16 family; the length
// is checked before the address bytes are read.
let family = unsafe { *u.sockaddr };
let bytes = u.sockaddr.cast::<u8>();
if family == AF_INET && u.sockaddr_length >= 8 {
let mut octets = [0u8; 4];
// SAFETY: sockaddr_in: family, port, then 4 address bytes.
unsafe { std::ptr::copy_nonoverlapping(bytes.add(4), octets.as_mut_ptr(), 4) };
addresses.push(std::net::IpAddr::from(octets));
} else if family == AF_INET6 && u.sockaddr_length >= 24 {
let mut octets = [0u8; 16];
// SAFETY: sockaddr_in6: family, port, flowinfo, then 16
// address bytes.
unsafe { std::ptr::copy_nonoverlapping(bytes.add(8), octets.as_mut_ptr(), 16) };
addresses.push(std::net::IpAddr::from(octets));
}
}
unicast = u.next;
}
adapters.push(WinAdapter {
name: wide(adapter.friendly_name),
mac,
up: adapter.oper_status == IF_OPER_STATUS_UP,
addresses,
});
node = adapter.next;
}
Ok(adapters)
}
#[cfg(windows)]
fn net_address(name: &str) -> Result<Value> {
// Get-NetIPAddress -AddressFamily IPv4's rows, from the adapter table
// instead of PowerShell.
let mut rows = Vec::new();
for adapter in windows_adapters(name)? {
for ip in &adapter.addresses {
if ip.is_ipv4() {
rows.push(net_record(vec![
("ip", Value::Str(ip.to_string())),
("interface", Value::Str(adapter.name.clone())),
]));
}
}
}
if rows.is_empty() {
return Err(crate::safety::not_found(
name,
"IPv4 address",
"on this host",
));
}
// One address is the address itself, as on the other platforms; several
// are listed with the interface each belongs to.
if let [Value::Record(rec)] = rows.as_slice() {
return Ok(rec.get("ip").cloned().unwrap_or(Value::Null));
}
Ok(Value::Array(rows))
}
#[cfg(target_os = "linux")]
fn net_address(name: &str) -> Result<Value> {
net_address_text(name, "hostname", &["-I"])
}
#[cfg(target_os = "macos")]
fn net_address(name: &str) -> Result<Value> {
net_address_text(name, "ipconfig", &["getifaddr", "en0"])
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn net_address_text(name: &str, tool: &str, argv: &[&str]) -> Result<Value> {
let out = net_run(name, tool, argv, NET_TOOL_SECS)?;
net_checked(name, tool, &out)?;
let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
if text.is_empty() {
return Err(crate::safety::not_found(
name,
"IPv4 address",
"on this host",
));
}
Ok(Value::Str(text))
}
#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
fn net_address(name: &str) -> Result<Value> {
Err(crate::safety::unimplemented(
name,
"the address is not read on this platform",
"run on Windows, Linux or macOS",
))
}
fn bi_net_dns_lookup(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "net_dns_lookup";
if input.is_some() {
return Err(crate::safety::bad_arg(
NAME,
"no pipeline input",
"piped input",
));
}
let host = net_host(NAME, &args, 0)?;
net_refuse_in_transaction(NAME)?;
net_cap(NAME, net_resolve(NAME, &host)?)
}
#[cfg(windows)]
fn net_resolve(name: &str, host: &str) -> Result<Vec<Value>> {
let script = crate::ps_script!(
"Resolve-DnsName {} | Select-Object IPAddress | ConvertTo-Json",
crate::safety::ps_quote(host)
);
let out = net_run(
name,
"powershell",
&["-NoProfile", "-NonInteractive", "-Command", script.as_str()],
NET_TOOL_SECS,
)?;
net_checked(name, "powershell", &out)?;
// A name can answer with CNAME records, which carry no address.
Ok(net_ps_items(name, &out.stdout)?
.iter()
.filter_map(|item| item.get("IPAddress").and_then(|v| v.as_str()))
.map(|ip| Value::Str(ip.to_string()))
.collect())
}
#[cfg(not(windows))]
fn net_resolve(name: &str, host: &str) -> Result<Vec<Value>> {
let out = net_run(name, "dig", &["+short", host], NET_TOOL_SECS)?;
net_checked(name, "dig", &out)?;
Ok(String::from_utf8_lossy(&out.stdout)
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(|l| Value::Str(l.to_string()))
.collect())
}
fn bi_net_dns_reverse(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "net_dns_reverse";
if input.is_some() {
return Err(crate::safety::bad_arg(
NAME,
"no pipeline input",
"piped input",
));
}
let ip = net_ip_literal(NAME, &args, 0)?;
net_refuse_in_transaction(NAME)?;
match net_reverse(NAME, &ip)?.into_iter().next() {
Some(name) => Ok(Value::Str(name)),
None => Err(crate::safety::not_found(
NAME,
"PTR record",
&ip.to_string(),
)),
}
}
#[cfg(windows)]
fn net_reverse(name: &str, ip: &std::net::IpAddr) -> Result<Vec<String>> {
let script = crate::ps_script!(
"Resolve-DnsName {} | Select-Object NameHost | ConvertTo-Json",
crate::safety::ps_quote(&ip.to_string())
);
let out = net_run(
name,
"powershell",
&["-NoProfile", "-NonInteractive", "-Command", script.as_str()],
NET_TOOL_SECS,
)?;
net_checked(name, "powershell", &out)?;
Ok(net_ps_items(name, &out.stdout)?
.iter()
.filter_map(|item| item.get("NameHost").and_then(|v| v.as_str()))
.map(str::to_string)
.collect())
}
#[cfg(not(windows))]
fn net_reverse(name: &str, ip: &std::net::IpAddr) -> Result<Vec<String>> {
let target = ip.to_string();
let out = net_run(
name,
"dig",
&["+short", "-x", target.as_str()],
NET_TOOL_SECS,
)?;
net_checked(name, "dig", &out)?;
Ok(String::from_utf8_lossy(&out.stdout)
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect())
}
/// Parse ping output into a structured Record with host, packets_sent, packets_received,
/// packet_loss_pct, min_rtt, avg_rtt, max_rtt. Works for both Windows and Unix ping output.
fn parse_ping_output(text: &str, host: &str) -> Value {
let mut rec = std::collections::BTreeMap::new();
rec.insert("host".to_string(), Value::Str(host.to_string()));
// Parse packet statistics: "X packets transmitted, Y received" (Unix)
// or "Packets: Sent = X, Received = Y, Lost = Z" (Windows)
for line in text.lines() {
let line = line.trim();
if line.contains("packets transmitted") || line.contains("Packets:") {
// Unix: "4 packets transmitted, 4 received, 0% packet loss"
// Windows: "Packets: Sent = 4, Received = 4, Lost = 0 (0% loss)"
let nums: Vec<i64> = line
.split(|c: char| !c.is_ascii_digit())
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse().ok())
.collect();
if nums.len() >= 2 {
rec.insert("packets_sent".to_string(), Value::Int(nums[0]));
rec.insert("packets_received".to_string(), Value::Int(nums[1]));
}
// Extract loss percentage
if let Some(pct_pos) = line.find('%') {
let before = &line[..pct_pos];
let loss_str = before
.rsplit(|c: char| !c.is_ascii_digit() && c != '.')
.next()
.unwrap_or("0");
if let Ok(loss) = loss_str.parse::<f64>() {
rec.insert("packet_loss_pct".to_string(), Value::Float(loss));
}
}
}
// Parse RTT: "rtt min/avg/max/mdev = 1.234/5.678/9.012/3.456 ms" (Unix)
// or "Minimum = 1ms, Maximum = 9ms, Average = 5ms" (Windows)
if line.contains("min/avg/max") || line.contains("rtt ") {
if let Some(eq_pos) = line.find('=') {
let values_str = &line[eq_pos + 1..];
let values: Vec<f64> = values_str
.split('/')
.filter_map(|s| s.trim().trim_end_matches("ms").trim().parse().ok())
.collect();
if values.len() >= 3 {
rec.insert("min_rtt".to_string(), Value::Float(values[0]));
rec.insert("avg_rtt".to_string(), Value::Float(values[1]));
rec.insert("max_rtt".to_string(), Value::Float(values[2]));
}
}
}
if line.contains("Minimum") && line.contains("Maximum") && line.contains("Average") {
// Windows: "Minimum = 1ms, Maximum = 9ms, Average = 5ms"
let nums: Vec<f64> = line
.split(|c: char| !c.is_ascii_digit() && c != '.')
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse().ok())
.collect();
if nums.len() >= 3 {
rec.insert("min_rtt".to_string(), Value::Float(nums[0]));
rec.insert("max_rtt".to_string(), Value::Float(nums[1]));
rec.insert("avg_rtt".to_string(), Value::Float(nums[2]));
}
}
}
// Include raw output as fallback
rec.insert("raw".to_string(), Value::Str(text.to_string()));
Value::Record(rec)
}
#[cfg(any(windows, target_os = "linux"))]
fn net_str_field_value(obj: &serde_json::Map<String, serde_json::Value>, key: &str) -> Value {
obj.get(key).cloned().map_or(Value::Null, json_to_value)
}
fn bi_net_ping(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "net_ping";
if input.is_some() {
return Err(crate::safety::bad_arg(
NAME,
"no pipeline input",
"piped input",
));
}
let host = net_host(NAME, &args, 0)?;
let count = net_int(NAME, &args, 1, Some(4), 1, 100)?;
net_refuse_in_transaction(NAME)?;
let flag = if cfg!(windows) { "-n" } else { "-c" };
let count_text = count.to_string();
// One probe per second on Unix, so the bound grows with the count.
let secs = NET_TOOL_SECS + 2 * count as u64;
let out = net_run(
NAME,
"ping",
&[flag, count_text.as_str(), host.as_str()],
secs,
)?;
let rec = parse_ping_output(&String::from_utf8_lossy(&out.stdout), &host);
// A host that does not answer exits nonzero yet still reports its loss, so
// that report is the answer. Only a run with no statistics is a failure.
if !out.status.success() && !net_has(&rec, "packets_received") {
return Err(crate::safety::tool_failed(
NAME,
"ping",
out.status.code(),
&net_diag(&out),
));
}
Ok(rec)
}
fn bi_net_latency(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "net_latency";
if input.is_some() {
return Err(crate::safety::bad_arg(
NAME,
"no pipeline input",
"piped input",
));
}
let host = net_host(NAME, &args, 0)?;
net_refuse_in_transaction(NAME)?;
let flag = if cfg!(windows) { "-n" } else { "-c" };
let out = net_run(NAME, "ping", &[flag, "1", host.as_str()], NET_TOOL_SECS)?;
if !out.status.success() {
return Err(crate::safety::tool_failed(
NAME,
"ping",
out.status.code(),
&net_diag(&out),
));
}
// The round-trip time the probe reported, in milliseconds. Timing the
// ping process itself would measure process start-up as well.
let rec = parse_ping_output(&String::from_utf8_lossy(&out.stdout), &host);
match net_float(&rec, "avg_rtt") {
Some(ms) => Ok(Value::Float(ms)),
None => Err(net_unparsed(NAME, "ping")),
}
}
/// One traceroute or tracert line: the hop number, the address that answered
/// (`*` when none did) and the round-trip times in milliseconds. Lines that
/// do not begin with a hop number, such as the header, are not hops.
fn net_hop(line: &str) -> Option<Value> {
let parts: Vec<&str> = line.split_whitespace().collect();
let hop = parts.first()?.parse::<i64>().ok()?;
let is_addr = |t: &str| t.parse::<std::net::IpAddr>().is_ok();
// tracert -d prints the address last; traceroute -n prints it after the hop.
let addr = if cfg!(windows) {
parts.last().copied().filter(|t| is_addr(t))
} else {
parts.iter().skip(1).copied().find(|t| is_addr(t))
};
let rtts: Vec<Value> = parts
.iter()
.skip(1)
.copied()
.filter(|t| Some(*t) != addr)
.filter_map(|t| t.trim_end_matches("ms").parse::<f64>().ok())
.map(Value::Float)
.collect();
let mut rec = NetRecord::new();
rec.insert("hop".to_string(), Value::Int(hop));
rec.insert(
"ip".to_string(),
Value::Str(addr.unwrap_or("*").to_string()),
);
rec.insert("rtt_ms".to_string(), Value::Array(rtts));
Some(Value::Record(rec))
}
fn bi_net_traceroute(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "net_traceroute";
if input.is_some() {
return Err(crate::safety::bad_arg(
NAME,
"no pipeline input",
"piped input",
));
}
let host = net_host(NAME, &args, 0)?;
// Thirty is the usual ceiling for a route; a larger request is refused.
let max_hops = net_int(NAME, &args, 1, Some(30), 1, 30)?;
net_refuse_in_transaction(NAME)?;
let hops_text = max_hops.to_string();
let (tool, argv): (&str, [&str; 4]) = if cfg!(windows) {
("tracert", ["-d", "-h", hops_text.as_str(), host.as_str()])
} else {
(
"traceroute",
["-n", "-m", hops_text.as_str(), host.as_str()],
)
};
let out = net_run(NAME, tool, &argv, 120)?;
net_checked(NAME, tool, &out)?;
let text = String::from_utf8_lossy(&out.stdout);
let hops: Vec<Value> = text
.lines()
.filter_map(net_hop)
.take(max_hops as usize)
.collect();
Ok(Value::Array(hops))
}
// ---- socket tables -------------------------------------------------------
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
fn net_socket_record(protocol: &str, state: &str, local: String, peer: String) -> Value {
net_record(vec![
("protocol", Value::Str(protocol.to_string())),
("state", Value::Str(state.to_string())),
("local", Value::Str(local)),
("peer", Value::Str(peer)),
])
}
/// The state names of the TCP table, as `Get-NetTCPConnection` reports them by
/// number. Every platform reports states in upper case, as `ss` and `netstat` do.
#[cfg(any(windows, test))]
fn windows_state_name(code: i64) -> String {
let name = match code {
1 => "CLOSED",
2 => "LISTEN",
3 => "SYN_SENT",
4 => "SYN_RECEIVED",
5 => "ESTABLISHED",
6 => "FIN_WAIT_1",
7 => "FIN_WAIT_2",
8 => "CLOSE_WAIT",
9 => "CLOSING",
10 => "LAST_ACK",
11 => "TIME_WAIT",
12 => "DELETE_TCB",
// MSFT_NetTCPConnection's Bound: a socket bound to a port with no
// listen and no peer, which netstat does not show at all.
100 => "BOUND",
_ => "UNKNOWN",
};
name.to_string()
}
/// A remote port from `Get-NetTCPConnection`. 0 is the table's word for "no
/// peer" (a listener or a bound socket), so it is read as 0 and rendered by the
/// caller. Any other value outside 1..=65535, or a missing one, is refused.
#[cfg(any(windows, test))]
fn net_remote_port(
name: &str,
o: &serde_json::Map<String, serde_json::Value>,
key: &str,
) -> Result<i64> {
o.get(key)
.and_then(|v| v.as_i64())
.filter(|p| (0..=65535).contains(p))
.ok_or_else(|| net_unparsed(name, "powershell"))
}
/// A local port from `Get-NetTCPConnection`. Missing, non-integer or 0 is
/// refused: the old `unwrap_or(0)` listed the socket as port 0, which no socket
/// has.
#[cfg(any(windows, test))]
fn net_socket_port(
name: &str,
o: &serde_json::Map<String, serde_json::Value>,
key: &str,
) -> Result<i64> {
o.get(key)
.and_then(|v| v.as_i64())
.filter(|p| (1..=65535).contains(p))
.ok_or_else(|| net_unparsed(name, "powershell"))
}
/// One row of the Windows TCP table, with the process that owns the socket.
/// `remote_port` is 0 when the socket has no peer: a listener or a bound socket.
#[cfg(windows)]
struct WinTcpRow {
owner: i64,
state: String,
local_addr: String,
local_port: i64,
peer_addr: String,
remote_port: i64,
}
/// The TCP table, read once through `Get-NetTCPConnection`. net_connections,
/// net_ports and lsof all read sockets here, so the table is read one way.
#[cfg(windows)]
fn win_tcp_table(name: &str) -> Result<Vec<WinTcpRow>> {
let script = "Get-NetTCPConnection | Select-Object LocalAddress,LocalPort,RemoteAddress,RemotePort,State,OwningProcess | ConvertTo-Json";
let out = net_run(
name,
"powershell",
&["-NoProfile", "-NonInteractive", "-Command", script],
NET_TOOL_SECS,
)?;
net_checked(name, "powershell", &out)?;
let mut rows = Vec::new();
for item in net_ps_items(name, &out.stdout)? {
let Some(o) = item.as_object() else {
return Err(net_unparsed(name, "powershell"));
};
// A missing, non-integer or non-string state is refused. A numeric code
// outside the named range is a real state with no name: UNKNOWN.
let state = match o.get("State") {
Some(serde_json::Value::Number(n)) => {
let code = n.as_i64().ok_or_else(|| net_unparsed(name, "powershell"))?;
windows_state_name(code)
}
Some(serde_json::Value::String(s)) if !s.trim().is_empty() => {
s.to_ascii_uppercase().replace('-', "_")
}
_ => return Err(net_unparsed(name, "powershell")),
};
// The owning pid is 0 for a socket that no process holds.
let owner = o
.get("OwningProcess")
.and_then(|v| v.as_i64())
.filter(|p| *p >= 0)
.ok_or_else(|| net_unparsed(name, "powershell"))?;
rows.push(WinTcpRow {
owner,
state,
local_addr: net_req_str(name, "powershell", o, "LocalAddress")?,
local_port: net_socket_port(name, o, "LocalPort")?,
peer_addr: net_req_str(name, "powershell", o, "RemoteAddress")?,
remote_port: net_remote_port(name, o, "RemotePort")?,
});
}
Ok(rows)
}
#[cfg(windows)]
fn net_socket_rows(name: &str) -> Result<Vec<Value>> {
Ok(win_tcp_table(name)?
.iter()
.map(|r| {
let local = format!("{}:{}", r.local_addr, r.local_port);
let peer = if r.remote_port == 0 {
"*:*".to_string()
} else {
format!("{}:{}", r.peer_addr, r.remote_port)
};
net_socket_record("tcp", &r.state, local, peer)
})
.collect())
}
/// lsof with no path on Windows: the TCP sockets a process holds, as `lsof -i`
/// reports them, from the table net_connections reads. A socket that no process
/// holds (owner 0) is left out, and so is one whose owner exited between the
/// two reads: lsof lists only the sockets it can attribute to a process.
#[cfg(windows)]
fn lsof_windows_sockets() -> Result<Value> {
const NAME: &str = "lsof";
let table = win_tcp_table(NAME)?;
let mut sys = System::new();
sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
let mut rows = Vec::new();
for row in table.iter().filter(|r| r.owner > 0) {
let pid = u32::try_from(row.owner).map_err(|_| net_unparsed(NAME, "powershell"))?;
let Some(process) = sys.process(sysinfo::Pid::from_u32(pid)) else {
continue;
};
// lsof names the command, not the image: `svchost.exe` is `svchost`.
let raw = process.name().to_string_lossy().into_owned();
let command = if raw.to_ascii_lowercase().ends_with(".exe") {
raw[..raw.len() - 4].to_string()
} else {
raw
};
let local = lsof_endpoint(&row.local_addr, row.local_port);
let name = if row.remote_port == 0 {
format!("{local} ({})", row.state)
} else {
let peer = lsof_endpoint(&row.peer_addr, row.remote_port);
format!("{local}->{peer} ({})", row.state)
};
let kind = if row.local_addr.contains(':') {
"IPv6"
} else {
"IPv4"
};
rows.push(net_record(vec![
("command", Value::Str(command)),
("pid", Value::Str(pid.to_string())),
("type", Value::Str(kind.to_string())),
("node", Value::Str("TCP".to_string())),
("name", Value::Str(name)),
]));
}
net_cap(NAME, rows)
}
/// An endpoint as lsof prints it with -n: a wildcard address is `*`, and an
/// IPv6 address is bracketed.
#[cfg(windows)]
fn lsof_endpoint(addr: &str, port: i64) -> String {
let host = if addr == "0.0.0.0" || addr == "::" {
"*".to_string()
} else if addr.contains(':') {
format!("[{addr}]")
} else {
addr.to_string()
};
format!("{host}:{port}")
}
// Restart Manager, as `rstrtmgr.dll` exports it. The session is a list of
// resources (files) that the Restart Manager maps to the processes holding
// them open. Declared by hand, as the other Windows backends are, so no crate
// is added for four functions.
#[cfg(windows)]
#[link(name = "rstrtmgr")]
extern "system" {
fn RmStartSession(session: *mut u32, flags: u32, key: *mut u16) -> u32;
fn RmRegisterResources(
session: u32,
files: u32,
file_names: *const *const u16,
applications: u32,
application_list: *const std::ffi::c_void,
services: u32,
service_names: *const *const u16,
) -> u32;
fn RmGetList(
session: u32,
needed: *mut u32,
count: *mut u32,
affected: *mut RmProcessInfo,
reboot_reasons: *mut u32,
) -> u32;
fn RmEndSession(session: u32) -> u32;
}
/// RM_PROCESS_INFO from `rmnoti.h`, laid out as C does: a RM_UNIQUE_PROCESS
/// (pid and FILETIME start time), then the application name (CCH_RM_MAX_APP_NAME
/// + 1 = 256 wide characters), the service short name (CCH_RM_MAX_SVC_NAME + 1 =
/// 64), and four 32-bit fields.
#[cfg(windows)]
#[repr(C)]
#[derive(Clone, Copy)]
struct RmProcessInfo {
process_id: u32,
start_low: u32,
start_high: u32,
app_name: [u16; 256],
service_name: [u16; 64],
app_type: i32,
app_status: u32,
ts_session_id: u32,
restartable: i32,
}
/// The Restart Manager session, ended on every path out of the lookup, including
/// an early error return or a panic.
#[cfg(windows)]
struct RmSession(u32);
#[cfg(windows)]
impl Drop for RmSession {
fn drop(&mut self) {
// SAFETY: the handle came from a successful RmStartSession and is ended
// here and nowhere else; the return code has no recovery to act on.
unsafe { RmEndSession(self.0) };
}
}
/// The most processes `lsof PATH` reports on Windows; more is refused.
#[cfg(windows)]
const RM_MAX_HOLDERS: u32 = 1024;
/// lsof with a path on Windows: the processes holding that file open, from the
/// Restart Manager (RmStartSession, RmRegisterResources, RmGetList, RmEndSession).
/// A directory is refused with E_BAD_ARG, because the Restart Manager takes files
/// only. A path that does not exist is E_NOT_FOUND. A UNC or device path, and a
/// drive that resolves to a network share, are refused and never probed.
///
/// Row shape matches the Linux and macOS `lsof -P -n` rows: a record with the
/// same lowercase keys. Only `command`, `pid` and `name` have a value here. `command`
/// is the image name without `.exe`, as on the socket arm (the Restart Manager's
/// application name only when the process is not visible to this user); `pid`
/// is the process id as a String, as on the other arms; `name` is the file's
/// canonical path. `user`, `fd`, `type`, `device`, `size/off` and `node` are Null,
/// because the Restart Manager does not report them.
#[cfg(windows)]
fn lsof_windows_path(path: &str) -> Result<Value> {
const NAME: &str = "lsof";
use std::os::windows::ffi::OsStrExt;
// exists() on a UNC or device string opens an SMB session, so the string is
// refused before the filesystem is asked anything.
if crate::safety::names_a_remote_share(path) {
return Err(crate::safety::bad_arg(
NAME,
"a local file path, not a UNC or device path",
"a path that begins with a drive letter",
));
}
let meta = match std::fs::metadata(path) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(crate::safety::not_found(NAME, "file", path));
}
Err(e) => {
return Err(crate::safety::bad_state(
NAME,
&format!("cannot read '{path}': {e}"),
"a file this user can read",
));
}
};
if meta.is_dir() {
return Err(crate::safety::bad_arg(
NAME,
"a file, not a directory: the Restart Manager lists the holders of files only",
"a file path",
));
}
let canonical = std::fs::canonicalize(path).map_err(|e| {
crate::safety::bad_state(
NAME,
&format!("cannot resolve '{path}': {e}"),
"a file this user can read",
)
})?;
// canonicalize returns the verbatim form (\\?\C:\...). The Restart Manager is
// given the plain drive path, so the four-character prefix is dropped without
// a lossy conversion. A verbatim UNC form means a share, which is refused.
let mut wide: Vec<u16> = canonical.as_os_str().encode_wide().collect();
let verbatim: [u16; 4] = [b'\\' as u16, b'\\' as u16, b'?' as u16, b'\\' as u16];
if wide.starts_with(&verbatim) {
wide.drain(..verbatim.len());
}
let unc: Vec<u16> = "UNC"
.encode_utf16()
.chain(std::iter::once(b'\\' as u16))
.collect();
if wide.starts_with(&unc) {
return Err(crate::safety::bad_arg(
NAME,
"a file on a network share, which the Restart Manager does not list",
"a file on a local drive",
));
}
wide.push(0);
let holders = lsof_rm_holders(NAME, &wide)?;
let file = String::from_utf16_lossy(&wide[..wide.len() - 1]);
// lsof names the command, as the socket arm above does: the image name
// without `.exe`. The Restart Manager's application name is a description
// ("Windows Command Processor"), used only for a process sysinfo cannot see.
let mut sys = System::new();
sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
let rows: Vec<Value> = holders
.iter()
.map(|p| {
let image = sys
.process(sysinfo::Pid::from_u32(p.process_id))
.map(|proc_| proc_.name().to_string_lossy().into_owned())
.map(|raw| {
if raw.to_ascii_lowercase().ends_with(".exe") {
raw[..raw.len() - 4].to_string()
} else {
raw
}
})
.filter(|n| !n.is_empty());
let app_len = p
.app_name
.iter()
.position(|&c| c == 0)
.unwrap_or(p.app_name.len());
let app = String::from_utf16_lossy(&p.app_name[..app_len]);
let command = image.or_else(|| (!app.is_empty()).then_some(app));
let mut rec = BTreeMap::new();
rec.insert(
"command".to_string(),
command.map_or(Value::Null, Value::Str),
);
rec.insert("pid".to_string(), Value::Str(p.process_id.to_string()));
for key in ["user", "fd", "type", "device", "size/off", "node"] {
rec.insert(key.to_string(), Value::Null);
}
rec.insert("name".to_string(), Value::Str(file.clone()));
Value::Record(rec)
})
.collect();
Ok(Value::Array(rows))
}
/// The processes holding one file open, by the Restart Manager. `path` is a
/// NUL-terminated wide string. The count is read first, then the list is read
/// into an array of that size; if the process set grew between the two calls
/// (ERROR_MORE_DATA), the count is read again, a bounded number of times.
#[cfg(windows)]
fn lsof_rm_holders(name: &str, path: &[u16]) -> Result<Vec<RmProcessInfo>> {
const ERROR_SUCCESS: u32 = 0;
const ERROR_ACCESS_DENIED: u32 = 5;
const ERROR_MORE_DATA: u32 = 234;
const ATTEMPTS: usize = 3;
let rm_error = |call: &str, code: u32| -> anyhow::Error {
let err = std::io::Error::from_raw_os_error(code as i32);
let detail = format!("{call} failed: {err} (Win32 {code})");
if code == ERROR_ACCESS_DENIED {
crate::safety::os_denied(
name,
format!("{name}: {detail}"),
"the file is not visible to this user; read a file this user owns",
)
} else {
crate::safety::bad_state(
name,
&detail,
"a file this user can read, and the Restart Manager service running",
)
}
};
let mut session = 0u32;
let mut key = [0u16; 33];
// SAFETY: the out-pointer and the 33-element key buffer (CCH_RM_SESSION_KEY + 1)
// are valid for the duration of the call.
let rc = unsafe { RmStartSession(&mut session, 0, key.as_mut_ptr()) };
if rc != ERROR_SUCCESS {
return Err(rm_error("RmStartSession", rc));
}
let session = RmSession(session);
let files = [path.as_ptr()];
// SAFETY: one resource, a NUL-terminated wide path that outlives the call; no
// applications or services are registered, so their pointers are not read.
let rc = unsafe {
RmRegisterResources(
session.0,
1,
files.as_ptr(),
0,
std::ptr::null(),
0,
std::ptr::null(),
)
};
if rc != ERROR_SUCCESS {
return Err(rm_error("RmRegisterResources", rc));
}
for _ in 0..ATTEMPTS {
// First call: no buffer, so the answer is the count alone.
let mut needed = 0u32;
let mut written = 0u32;
let mut reasons = 0u32;
// SAFETY: a null buffer with a zero capacity is the documented count query.
let rc = unsafe {
RmGetList(
session.0,
&mut needed,
&mut written,
std::ptr::null_mut(),
&mut reasons,
)
};
if rc == ERROR_SUCCESS && needed == 0 {
return Ok(Vec::new());
}
if rc != ERROR_MORE_DATA {
return Err(rm_error("RmGetList", rc));
}
if needed > RM_MAX_HOLDERS {
return Err(crate::safety::budget_exceeded(
name,
format!("{needed} processes hold this file open; the limit is {RM_MAX_HOLDERS}"),
"a file fewer processes hold open",
));
}
// Second call: the buffer holds the count just read.
let mut infos = vec![
// SAFETY: RmProcessInfo is plain integers and u16 arrays, all valid as zero.
unsafe { std::mem::zeroed::<RmProcessInfo>() }; needed as usize
];
let mut capacity = needed;
let mut needed_now = needed;
// SAFETY: `infos` has `capacity` elements, which the call is told; the
// out-counts are valid locals.
let rc = unsafe {
RmGetList(
session.0,
&mut needed_now,
&mut capacity,
infos.as_mut_ptr(),
&mut reasons,
)
};
if rc == ERROR_SUCCESS {
infos.truncate(capacity as usize);
return Ok(infos);
}
if rc != ERROR_MORE_DATA {
return Err(rm_error("RmGetList", rc));
}
if needed_now > RM_MAX_HOLDERS {
return Err(crate::safety::budget_exceeded(
name,
format!(
"{needed_now} processes hold this file open; the limit is {RM_MAX_HOLDERS}"
),
"a file fewer processes hold open",
));
}
}
Err(crate::safety::bad_state(
name,
"the processes holding this file kept changing while they were listed",
"a file whose holders are stable; try again",
))
}
#[cfg(target_os = "linux")]
fn net_socket_rows(name: &str) -> Result<Vec<Value>> {
// All TCP and UDP sockets, numeric; `ss` prints one header line first.
let out = net_run(name, "ss", &["-tuan"], NET_TOOL_SECS)?;
net_checked(name, "ss", &out)?;
let text = String::from_utf8_lossy(&out.stdout);
let mut rows = Vec::new();
for line in text.lines().skip(1).filter(|l| !l.trim().is_empty()) {
// Netid State Recv-Q Send-Q Local:Port Peer:Port [Process]
let p: Vec<&str> = line.split_whitespace().collect();
if p.len() < 6 {
return Err(net_unparsed(name, "ss"));
}
rows.push(net_socket_record(
&p[0].to_ascii_lowercase(),
&p[1].to_ascii_uppercase(),
p[4].to_string(),
p[5].to_string(),
));
}
Ok(rows)
}
#[cfg(target_os = "macos")]
fn net_socket_rows(name: &str) -> Result<Vec<Value>> {
let out = net_run(name, "netstat", &["-an", "-p", "tcp"], NET_TOOL_SECS)?;
net_checked(name, "netstat", &out)?;
let text = String::from_utf8_lossy(&out.stdout);
macos_netstat_rows(name, &text)
}
/// The TCP table as macOS netstat prints it. An endpoint is written `addr.port`
/// (`127.0.0.1.49817`, `*.*`), not `addr:port`; macos_netstat_endpoint reads it
/// in the form the other platforms report.
#[cfg(any(target_os = "macos", test))]
fn macos_netstat_rows(name: &str, text: &str) -> Result<Vec<Value>> {
let mut rows = Vec::new();
// Two header lines: the title and the column names.
for line in text.lines().skip(2).filter(|l| !l.trim().is_empty()) {
// Proto Recv-Q Send-Q Local Foreign State
let p: Vec<&str> = line.split_whitespace().collect();
if p.len() < 6 {
return Err(net_unparsed(name, "netstat"));
}
rows.push(net_socket_record(
"tcp",
&p[5].to_ascii_uppercase(),
macos_netstat_endpoint(p[3]),
macos_netstat_endpoint(p[4]),
));
}
Ok(rows)
}
/// `addr.port` as macOS netstat writes it, read as `addr:port`. The port follows
/// the last dot; an IPv6 address is bracketed, as ss prints it.
#[cfg(any(target_os = "macos", test))]
fn macos_netstat_endpoint(text: &str) -> String {
match text.rsplit_once('.') {
Some((addr, port)) if addr.contains(':') => format!("[{addr}]:{port}"),
Some((addr, port)) => format!("{addr}:{port}"),
None => text.to_string(),
}
}
#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
fn net_socket_rows(name: &str) -> Result<Vec<Value>> {
Err(crate::safety::unimplemented(
name,
"sockets are not read on this platform",
"run on Windows, Linux or macOS",
))
}
fn bi_net_connections(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "net_connections";
no_args_or_input(NAME, &args, &input)?;
net_cap(NAME, net_socket_rows(NAME)?)
}
#[path = "tcp_sockets.rs"]
mod tcp_sockets;
fn bi_net_listen(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
tcp_sockets::guard_and_open("net_listen", args, input, true)
}
fn bi_net_connect(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
tcp_sockets::guard_and_open("net_connect", args, input, false)
}
fn bi_net_send(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
tcp_sockets::guard_and_send(args, input)
}
fn bi_net_recv(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
tcp_sockets::guard_and_recv(args, input)
}
fn bi_net_close(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
tcp_sockets::guard_and_close(args, input)
}
/// The sockets that are listening: TCP in LISTEN and UDP sockets bound and
/// waiting for datagrams (UNCONN).
fn bi_net_ports(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "net_ports";
no_args_or_input(NAME, &args, &input)?;
let listening: Vec<Value> = net_socket_rows(NAME)?
.into_iter()
.filter(net_is_listening)
.collect();
net_cap(NAME, listening)
}
fn net_is_listening(row: &Value) -> bool {
match row {
Value::Record(r) => matches!(
r.get("state"),
Some(Value::Str(s)) if s == "LISTEN" || s == "UNCONN"
),
_ => false,
}
}
// ---- neighbours, routes and counters -------------------------------------
/// The ARP table. Unix prints `host (ip) at mac ... on iface`; Windows prints
/// `ip mac type` under an `Interface:` header, which is skipped.
fn net_arp_rows(text: &str) -> Vec<Value> {
text.lines()
.filter_map(|l| {
let parts: Vec<&str> = l.split_whitespace().collect();
if parts.len() < 2 {
return None;
}
let mut rec = NetRecord::new();
if let (Some(start), Some(end)) = (l.find('('), l.find(')')) {
if start < end {
rec.insert("ip".to_string(), Value::Str(l[start + 1..end].to_string()));
}
if let Some(at) = parts.iter().position(|&p| p == "at") {
if let Some(mac) = parts.get(at + 1) {
rec.insert("mac".to_string(), Value::Str(mac.to_string()));
}
}
if let Some(on) = parts.iter().position(|&p| p == "on") {
if let Some(iface) = parts.get(on + 1) {
rec.insert("interface".to_string(), Value::Str(iface.to_string()));
}
}
} else if parts.len() >= 3 && (parts[0].contains('.') || parts[0].contains(':')) {
rec.insert("ip".to_string(), Value::Str(parts[0].to_string()));
rec.insert("mac".to_string(), Value::Str(parts[1].to_string()));
rec.insert("type".to_string(), Value::Str(parts[2].to_string()));
}
(!rec.is_empty()).then_some(Value::Record(rec))
})
.collect()
}
fn bi_net_arp(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "net_arp";
no_args_or_input(NAME, &args, &input)?;
let out = net_run(NAME, "arp", &["-a"], NET_TOOL_SECS)?;
net_checked(NAME, "arp", &out)?;
let text = String::from_utf8_lossy(&out.stdout);
net_cap(NAME, net_arp_rows(&text))
}
fn bi_net_route(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "net_route";
no_args_or_input(NAME, &args, &input)?;
net_cap(NAME, net_route_rows(NAME)?)
}
#[cfg(windows)]
fn net_route_rows(name: &str) -> Result<Vec<Value>> {
let script = "Get-NetRoute | Select-Object DestinationPrefix,NextHop,InterfaceAlias,RouteMetric | ConvertTo-Json";
let out = net_run(
name,
"powershell",
&["-NoProfile", "-NonInteractive", "-Command", script],
NET_TOOL_SECS,
)?;
net_checked(name, "powershell", &out)?;
let mut rows = Vec::new();
for item in net_ps_items(name, &out.stdout)? {
let Some(o) = item.as_object() else {
return Err(net_unparsed(name, "powershell"));
};
rows.push(net_record(vec![
("destination", net_str_field_value(o, "DestinationPrefix")),
("gateway", net_str_field_value(o, "NextHop")),
("interface", net_str_field_value(o, "InterfaceAlias")),
("metric", net_str_field_value(o, "RouteMetric")),
]));
}
Ok(rows)
}
#[cfg(target_os = "linux")]
fn net_route_rows(name: &str) -> Result<Vec<Value>> {
let out = net_run(name, "ip", &["-j", "route"], NET_TOOL_SECS)?;
net_checked(name, "ip", &out)?;
let json: serde_json::Value =
serde_json::from_slice(&out.stdout).map_err(|_| net_unparsed(name, "ip"))?;
let serde_json::Value::Array(routes) = json else {
return Err(net_unparsed(name, "ip"));
};
let mut rows = Vec::new();
for route in &routes {
let Some(o) = route.as_object() else {
return Err(net_unparsed(name, "ip"));
};
rows.push(net_record(vec![
("destination", net_str_field_value(o, "dst")),
("gateway", net_str_field_value(o, "gateway")),
("interface", net_str_field_value(o, "dev")),
("metric", net_str_field_value(o, "metric")),
]));
}
Ok(rows)
}
#[cfg(target_os = "macos")]
fn net_route_rows(name: &str) -> Result<Vec<Value>> {
let out = net_run(name, "netstat", &["-rn"], NET_TOOL_SECS)?;
net_checked(name, "netstat", &out)?;
let text = String::from_utf8_lossy(&out.stdout);
// Four header lines precede the table.
let rows = text
.lines()
.skip(4)
.filter_map(|l| {
let parts: Vec<&str> = l.split_whitespace().collect();
(parts.len() >= 4).then(|| {
net_record(vec![
("destination", Value::Str(parts[0].to_string())),
("gateway", Value::Str(parts[1].to_string())),
("flags", Value::Str(parts[2].to_string())),
("interface", Value::Str(parts[parts.len() - 1].to_string())),
])
})
})
.collect();
Ok(rows)
}
#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
fn net_route_rows(name: &str) -> Result<Vec<Value>> {
Err(crate::safety::unimplemented(
name,
"routes are not read on this platform",
"run on Windows, Linux or macOS",
))
}
/// The counters in `netstat -s` text as records of `section` (the heading the
/// counter sits under, or Null), `counter` (its name) and `value` (an Int, or
/// Null). Windows prints `Name = N`; Linux and macOS print `N name` (macOS under
/// an unindented `ip:`-style heading), and `name: N` where a counter has a
/// colon. A line with no number keeps its text as the counter, with value Null,
/// rather than being dropped.
fn netstat_records(text: &str) -> Vec<Value> {
let mut section: Option<String> = None;
let mut rows = Vec::new();
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if !line.starts_with(char::is_whitespace) {
section = Some(trimmed.trim_end_matches(':').trim().to_string());
continue;
}
let (counter, value) = netstat_counter(trimmed);
rows.push(net_record(vec![
(
"section",
section.clone().map(Value::Str).unwrap_or(Value::Null),
),
("counter", Value::Str(counter)),
("value", value.map(Value::Int).unwrap_or(Value::Null)),
]));
}
rows
}
/// The name and integer value of one indented `netstat -s` line. See
/// `netstat_records` for the spellings read.
fn netstat_counter(line: &str) -> (String, Option<i64>) {
if let Some((name, value)) = line.split_once('=') {
if let Ok(n) = value.trim().parse::<i64>() {
return (name.trim().to_string(), Some(n));
}
}
if let Some((first, rest)) = line.split_once(char::is_whitespace) {
if let Ok(n) = first.parse::<i64>() {
return (rest.trim().to_string(), Some(n));
}
}
if let Some((name, value)) = line.split_once(':') {
if let Ok(n) = value.trim().parse::<i64>() {
return (name.trim().to_string(), Some(n));
}
}
(line.to_string(), None)
}
fn bi_net_stats(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "net_stats";
no_args_or_input(NAME, &args, &input)?;
let out = net_run(NAME, "netstat", &["-s"], NET_TOOL_SECS)?;
net_checked(NAME, "netstat", &out)?;
if out.stdout.len() > NET_STATS_MAX {
return Err(crate::safety::budget_exceeded(
NAME,
format!(
"netstat -s wrote {} bytes, over the {NET_STATS_MAX}-byte limit",
out.stdout.len()
),
"read the counters with a narrower tool",
));
}
let text = String::from_utf8_lossy(&out.stdout);
Ok(Value::Array(netstat_records(&text)))
}
fn bi_net_bandwidth(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "net_bandwidth";
no_args_or_input(NAME, &args, &input)?;
net_cap(NAME, net_counter_rows(NAME)?)
}
#[cfg(target_os = "linux")]
fn net_counter_rows(name: &str) -> Result<Vec<Value>> {
let path = "/proc/net/dev";
let text =
std::fs::read_to_string(path).map_err(|e| crate::safety::fs_error(name, path, &e))?;
let mut rows = Vec::new();
// Two header lines. Each interface row is `name: ` then 16 counters, of
// which the 1st, 2nd, 9th and 10th are the receive and transmit totals.
for line in text.lines().skip(2) {
let Some((iface, rest)) = line.split_once(':') else {
return Err(net_unparsed(name, "/proc/net/dev"));
};
let f: Vec<&str> = rest.split_whitespace().collect();
if f.len() < 10 {
return Err(net_unparsed(name, "/proc/net/dev"));
}
let counter = |s: &str| -> Result<i64> {
s.parse::<i64>()
.map_err(|_| net_unparsed(name, "/proc/net/dev"))
};
rows.push(net_record(vec![
("interface", Value::Str(iface.trim().to_string())),
("rx_bytes", Value::Int(counter(f[0])?)),
("rx_packets", Value::Int(counter(f[1])?)),
("tx_bytes", Value::Int(counter(f[8])?)),
("tx_packets", Value::Int(counter(f[9])?)),
]));
}
Ok(rows)
}
#[cfg(target_os = "macos")]
fn net_counter_rows(name: &str) -> Result<Vec<Value>> {
let out = net_run(name, "netstat", &["-ib"], NET_TOOL_SECS)?;
net_checked(name, "netstat", &out)?;
let text = String::from_utf8_lossy(&out.stdout);
let mut rows = Vec::new();
// Name Mtu Network Address Ipkts Ierrs Ibytes Opkts Oerrs Obytes
for line in text.lines().skip(1).filter(|l| !l.trim().is_empty()) {
let p: Vec<&str> = line.split_whitespace().collect();
if p.len() < 10 {
continue;
}
let num = |s: &str| -> Result<i64> {
s.parse::<i64>().map_err(|_| net_unparsed(name, "netstat"))
};
rows.push(net_record(vec![
("interface", Value::Str(p[0].to_string())),
("rx_bytes", Value::Int(num(p[6])?)),
("rx_packets", Value::Int(num(p[4])?)),
("tx_bytes", Value::Int(num(p[9])?)),
("tx_packets", Value::Int(num(p[7])?)),
]));
}
Ok(rows)
}
/// One adapter row from `Get-NetAdapterStatistics`. Every counter is required:
/// a missing or non-integer one is refused. It used to be read as 0, so an
/// adapter without counters reported zero traffic.
#[cfg(any(windows, test))]
fn net_counter_record(name: &str, o: &serde_json::Map<String, serde_json::Value>) -> Result<Value> {
let count = |key: &str| {
o.get(key)
.and_then(|v| v.as_i64())
.ok_or_else(|| net_unparsed(name, "powershell"))
};
Ok(net_record(vec![
(
"interface",
Value::Str(net_req_str(name, "powershell", o, "Name")?),
),
("rx_bytes", Value::Int(count("ReceivedBytes")?)),
("tx_bytes", Value::Int(count("SentBytes")?)),
("rx_packets", Value::Int(count("ReceivedUnicastPackets")?)),
("tx_packets", Value::Int(count("SentUnicastPackets")?)),
]))
}
#[cfg(windows)]
fn net_counter_rows(name: &str) -> Result<Vec<Value>> {
let script = "Get-NetAdapterStatistics | Select-Object Name,ReceivedBytes,SentBytes,ReceivedUnicastPackets,SentUnicastPackets | ConvertTo-Json";
let out = net_run(
name,
"powershell",
&["-NoProfile", "-NonInteractive", "-Command", script],
NET_TOOL_SECS,
)?;
net_checked(name, "powershell", &out)?;
let mut rows = Vec::new();
for item in net_ps_items(name, &out.stdout)? {
let Some(o) = item.as_object() else {
return Err(net_unparsed(name, "powershell"));
};
rows.push(net_counter_record(name, o)?);
}
Ok(rows)
}
#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
fn net_counter_rows(name: &str) -> Result<Vec<Value>> {
Err(crate::safety::unimplemented(
name,
"interface counters are not read on this platform",
"run on Windows, Linux or macOS",
))
}
fn bi_net_whois(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "net_whois";
if input.is_some() {
return Err(crate::safety::bad_arg(
NAME,
"no pipeline input",
"piped input",
));
}
let domain = net_host(NAME, &args, 0)?;
net_refuse_in_transaction(NAME)?;
let out = net_run(NAME, "whois", &[domain.as_str()], NET_TOOL_SECS)?;
net_checked(NAME, "whois", &out)?;
let full = String::from_utf8_lossy(&out.stdout).to_string();
let truncated = full.len() > WHOIS_RAW_MAX;
let text: &str = if truncated {
let mut end = WHOIS_RAW_MAX;
while !full.is_char_boundary(end) {
end -= 1;
}
&full[..end]
} else {
&full
};
let mut rec = NetRecord::new();
rec.insert("domain".to_string(), Value::Str(domain.clone()));
for line in text.lines() {
let Some((key, val)) = line.trim().split_once(':') else {
continue;
};
let key = key.trim().to_lowercase().replace([' ', '-'], "_");
let val = val.trim().to_string();
if val.is_empty() {
continue;
}
match key.as_str() {
"registrar" => {
rec.insert("registrar".to_string(), Value::Str(val));
}
"creation_date" | "created" => {
rec.insert("creation_date".to_string(), Value::Str(val));
}
"registry_expiry_date" | "expiration_date" | "expires" => {
rec.insert("expiration_date".to_string(), Value::Str(val));
}
"updated_date" => {
rec.insert("updated_date".to_string(), Value::Str(val));
}
"domain_status" => {
let statuses = rec
.entry("status".to_string())
.or_insert_with(|| Value::Array(vec![]));
if let Value::Array(arr) = statuses {
arr.push(Value::Str(
val.split_whitespace().next().unwrap_or(&val).to_string(),
));
}
}
"name_server" => {
let ns = rec
.entry("name_servers".to_string())
.or_insert_with(|| Value::Array(vec![]));
if let Value::Array(arr) = ns {
arr.push(Value::Str(val.to_lowercase()));
}
}
"registrant_organization" | "registrant_org" => {
rec.insert("registrant_org".to_string(), Value::Str(val));
}
"registrant_country" => {
rec.insert("registrant_country".to_string(), Value::Str(val));
}
"dnssec" => {
rec.insert("dnssec".to_string(), Value::Str(val));
}
_ => {}
}
}
rec.insert("raw".to_string(), Value::Str(text.to_string()));
rec.insert("raw_truncated".to_string(), Value::Bool(truncated));
Ok(Value::Record(rec))
}
/// The address a net_scan call probes. Only this machine is probed: localhost,
/// 127.0.0.1 and ::1 (brackets allowed). Any other host is refused by policy
/// with E_POLICY_DENY, whatever IRONSHELL_NET_ALLOW says, because a port scan of
/// a remote host is reconnaissance rather than a request the shell should make.
fn net_loopback(name: &str, args: &[Value]) -> Result<std::net::IpAddr> {
let host = net_host(name, args, 0)?;
let bare = host.trim_start_matches('[').trim_end_matches(']');
let ip = if bare.eq_ignore_ascii_case("localhost") {
std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
} else {
bare.parse::<std::net::IpAddr>().map_err(|_| {
crate::safety::policy_deny(
name,
format!("{host} is not this machine; {name} only probes loopback"),
"scan localhost, 127.0.0.1 or ::1",
)
})?
};
if !ip.is_loopback() {
return Err(crate::safety::policy_deny(
name,
format!("{host} is not a loopback address; {name} only probes this machine"),
"scan localhost, 127.0.0.1 or ::1; this builtin does not probe other hosts",
));
}
Ok(ip)
}
/// Ports probed at once. Each connect has its own thread, so a batch costs one
/// connect's wait, not one per port.
const NET_SCAN_BATCH: i64 = 128;
/// The state a connect attempt reports: open when it was accepted, closed when
/// it was refused, filtered when no answer came within the wait.
///
/// On Windows a refused loopback connect takes about two seconds to report, and
/// `connect_timeout` given less than that reports it as a timeout, so a closed
/// port reads as filtered when the wait is that short. Plain `connect` has the
/// same two-second refusal; the wait is what bounds the result either way.
fn net_state(
name: &str,
port: i64,
answer: std::result::Result<
std::io::Result<std::net::TcpStream>,
std::sync::mpsc::RecvTimeoutError,
>,
) -> Result<&'static str> {
match answer {
Ok(Ok(_accepted)) => Ok("open"),
Ok(Err(e)) if e.kind() == std::io::ErrorKind::ConnectionRefused => Ok("closed"),
// connect_timeout reports no answer as TimedOut; that is filtered, the
// same as the wait running out below.
Ok(Err(e)) if e.kind() == std::io::ErrorKind::TimedOut => Ok("filtered"),
Ok(Err(e)) => Err(anyhow::Error::new(crate::safety::SafetyError {
code: crate::safety::ErrorCode::Io,
message: format!("{name}: probing port {port} failed: {e}"),
builtin: name.to_string(),
hint: "the loopback connect failed for a reason other than refusal; the port state is unknown".to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: "an accepted or refused connect".to_string(),
got: format!("{:?}", e.kind()),
})),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Ok("filtered"),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
Err(anyhow::Error::new(crate::safety::SafetyError {
code: crate::safety::ErrorCode::Io,
message: format!("{name}: probing port {port} ended without an answer"),
builtin: name.to_string(),
hint: "the connect thread stopped before reporting; the port state is unknown"
.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: "an accepted or refused connect".to_string(),
got: "no answer".to_string(),
}))
}
}
}
/// net_scan(host, first_port, last_port [, timeout_ms]): the state of each TCP
/// port in an inclusive range on this machine, as {port, state} records with
/// state open, closed or filtered.
///
/// Ports are probed 128 at a time; a batch waits at most one timeout, and the
/// whole call is bounded at 30 s, checked between batches. Only 1024 ports may
/// be asked for in one call. Probing another host is refused; see net_loopback.
fn bi_net_scan(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "net_scan";
if input.is_some() {
return Err(crate::safety::bad_arg(
NAME,
"no pipeline input",
"piped input",
));
}
let ip = net_loopback(NAME, &args)?;
let first = net_int(NAME, &args, 1, None, 1, 65535)?;
let last = net_int(NAME, &args, 2, None, 1, 65535)?;
let timeout_ms = net_int(NAME, &args, 3, Some(3000), 1, 10000)?;
if first > last {
return Err(crate::safety::bad_arg(
NAME,
"first_port <= last_port",
&format!("{first}..{last}"),
));
}
let count = last - first + 1;
if count > NET_SCAN_MAX_PORTS {
return Err(crate::safety::bad_arg(
NAME,
&format!("a range of at most {NET_SCAN_MAX_PORTS} ports"),
&format!("{count} ports"),
));
}
net_refuse_in_transaction(NAME)?;
let started = std::time::Instant::now();
let deadline = std::time::Duration::from_secs(NET_SCAN_DEADLINE_SECS);
let wait = std::time::Duration::from_millis(timeout_ms as u64);
let mut rows = Vec::with_capacity(count as usize);
let mut batch_start = first;
while batch_start <= last {
if started.elapsed() > deadline {
return Err(crate::safety::budget_exceeded(
NAME,
format!(
"stopped after {} of {count} ports at the {NET_SCAN_DEADLINE_SECS} s limit",
rows.len()
),
"scan a narrower port range, or a shorter timeout_ms",
));
}
let batch_end = (batch_start + NET_SCAN_BATCH - 1).min(last);
// Every connect in the batch starts now, so they share one wait. Each
// connect is bounded by the wait and by what is left of the call's
// deadline, so no connect thread outlives the call by more than that.
let per_port = wait
.min(deadline.saturating_sub(started.elapsed()))
.max(std::time::Duration::from_millis(1));
let pending: Vec<_> = (batch_start..=batch_end)
.map(|port| {
let (sender, receiver) = std::sync::mpsc::channel();
let addr = std::net::SocketAddr::new(ip, port as u16);
std::thread::spawn(move || {
// macOS now and then fails a non-blocking loopback connect
// with EINVAL (seen on a CI runner, next to a listening
// port). One more try on a fresh socket, inside the same
// wait, settles it; a second EINVAL is still reported.
let begun = std::time::Instant::now();
let mut answer = std::net::TcpStream::connect_timeout(&addr, per_port);
if matches!(&answer, Err(e) if e.kind() == std::io::ErrorKind::InvalidInput) {
let left = per_port.saturating_sub(begun.elapsed());
if !left.is_zero() {
answer = std::net::TcpStream::connect_timeout(&addr, left);
}
}
let _ = sender.send(answer);
});
(port, receiver)
})
.collect();
let batch_deadline = std::time::Instant::now() + wait;
for (port, receiver) in pending {
let remaining = batch_deadline.saturating_duration_since(std::time::Instant::now());
let state = net_state(NAME, port, receiver.recv_timeout(remaining))?;
rows.push(net_record(vec![
("port", Value::Int(port)),
("state", Value::Str(state.to_string())),
]));
}
batch_start = batch_end + 1;
}
Ok(Value::Array(rows))
}
// ============================================================================
// 290-309: System Information
// ============================================================================
/// The distribution name for `sys_info`, or None when no probe answers. A
/// probe that fails, times out or prints nothing is None, not an error: the
/// caller reports the field as null and names it in `missing`.
fn os_name_probe() -> Option<String> {
#[cfg(target_os = "windows")]
{
let mut cmd = crate::safety::child_command("powershell");
cmd.args([
"-Command",
"(Get-CimInstance Win32_OperatingSystem).Caption",
]);
let output = bounded_cmd_output("sys_info", "powershell", cmd, PROBE_DEADLINE_SECS).ok()?;
if !output.status.success() {
return None;
}
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
return (!name.is_empty()).then_some(name);
}
#[cfg(target_os = "linux")]
{
let release = std::fs::read_to_string("/etc/os-release").ok()?;
let name = release
.lines()
.find_map(|line| line.strip_prefix("PRETTY_NAME="))?
.trim_matches('"')
.to_string();
return (!name.is_empty()).then_some(name);
}
#[cfg(target_os = "macos")]
{
let mut name_cmd = crate::safety::child_command("sw_vers");
name_cmd.args(["-productName"]);
let name = bounded_cmd_output("sys_info", "sw_vers", name_cmd, PROBE_DEADLINE_SECS)
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|n| !n.is_empty())?;
let mut ver_cmd = crate::safety::child_command("sw_vers");
ver_cmd.args(["-productVersion"]);
let ver = bounded_cmd_output("sys_info", "sw_vers", ver_cmd, PROBE_DEADLINE_SECS)
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
return Some(format!("{} {}", name, ver).trim().to_string());
}
#[allow(unreachable_code)]
None
}
/// The host summary. Every field has its key on every OS. A field this host
/// cannot answer is null, and its name is listed in `missing`, so a caller can
/// tell an unknown value from an empty one.
fn bi_sys_info(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut rec = std::collections::BTreeMap::new();
rec.insert(
"os".to_string(),
Value::Str(std::env::consts::OS.to_string()),
);
rec.insert(
"arch".to_string(),
Value::Str(std::env::consts::ARCH.to_string()),
);
rec.insert(
"family".to_string(),
Value::Str(std::env::consts::FAMILY.to_string()),
);
rec.insert(
"os_name".to_string(),
os_name_probe().map(Value::Str).unwrap_or(Value::Null),
);
let missing: Vec<Value> = rec
.iter()
.filter(|(_, v)| matches!(v, Value::Null))
.map(|(k, _)| Value::Str(k.clone()))
.collect();
rec.insert("missing".to_string(), Value::Array(missing));
Ok(Value::Record(rec))
}
fn bi_sys_hostname(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
#[cfg(target_os = "windows")]
{
if let Ok(name) = std::env::var("COMPUTERNAME") {
return Ok(Value::Str(name));
}
}
#[cfg(unix)]
{
// gethostname(2): no subprocess, and no dependency on a `hostname`
// binary, which minimal containers do not have.
let mut buf = [0u8; 256];
let rc = unsafe { libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len()) };
if rc == 0 {
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
return Ok(Value::Str(
String::from_utf8_lossy(&buf[..end]).into_owned(),
));
}
}
// Every machine has a hostname; not finding one is a failure, and a null
// here read as an answer.
#[allow(unreachable_code)]
Err(crate::safety::tool_failed(
"sys_hostname",
"hostname",
None,
"the hostname could not be read",
))
}
fn bi_sys_os(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
Ok(Value::Str(std::env::consts::OS.to_string()))
}
fn bi_sys_arch(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
Ok(Value::Str(std::env::consts::ARCH.to_string()))
}
/// The trimmed stdout of a probe whose exit status is part of its answer.
///
/// A non-zero exit is refused with its code and stderr. Before this, a failed
/// `uname` or `cmd /C ver` returned whatever reached stdout, usually nothing,
/// as the platform's answer.
fn probe_stdout(builtin: &str, tool: &str, output: std::process::Output) -> Result<String> {
if !output.status.success() {
return Err(crate::safety::tool_failed(
builtin,
tool,
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
/// Run one probe under the probe deadline and return its checked stdout.
fn probe_text(builtin: &str, tool: &str, cmd: std::process::Command) -> Result<String> {
let output = bounded_cmd_output(builtin, tool, cmd, PROBE_DEADLINE_SECS)?;
probe_stdout(builtin, tool, output)
}
/// A probe for a tool that may not be installed at all. Absent is `Ok(None)`;
/// installed but failing, or unable to start for any other reason, is an error.
fn probe_text_if_installed(
builtin: &str,
tool: &str,
cmd: std::process::Command,
) -> Result<Option<String>> {
match bounded_cmd_output(builtin, tool, cmd, PROBE_DEADLINE_SECS) {
Ok(output) => probe_stdout(builtin, tool, output).map(Some),
Err(e) if is_tool_missing(&e) => Ok(None),
Err(e) => Err(e),
}
}
/// The Windows version from the kernel: major, minor, build and, from the
/// registry, the update revision.
///
/// Every Windows version probe ran `cmd /C ver` and parsed
/// `Microsoft Windows [Version 10.0.26200.9711]`. That started a process per
/// call and failed on a host whose display language translates "Version", so
/// platform_snapshot refused on those hosts. RtlGetVersion, unlike
/// GetVersionEx, is not capped by the application manifest; the update
/// revision is only in the registry.
#[cfg(target_os = "windows")]
struct WindowsVersion {
major: u32,
minor: u32,
build: u32,
revision: Option<u32>,
}
#[cfg(target_os = "windows")]
impl WindowsVersion {
fn read(builtin: &str) -> Result<WindowsVersion> {
#[repr(C)]
struct OsVersionInfoW {
size: u32,
major: u32,
minor: u32,
build: u32,
platform: u32,
service_pack: [u16; 128],
}
#[link(name = "ntdll")]
extern "system" {
fn RtlGetVersion(info: *mut OsVersionInfoW) -> i32;
}
let mut info = OsVersionInfoW {
size: std::mem::size_of::<OsVersionInfoW>() as u32,
major: 0,
minor: 0,
build: 0,
platform: 0,
service_pack: [0; 128],
};
// SAFETY: `info` is an OSVERSIONINFOW with its size field set.
let status = unsafe { RtlGetVersion(&mut info) };
if status != 0 {
return Err(crate::safety::bad_state(
builtin,
&format!("RtlGetVersion returned NTSTATUS {status:#x}"),
"a Windows kernel that reports its version",
));
}
let revision = windows_hklm_dword(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion", "UBR")
.ok()
.flatten();
Ok(WindowsVersion {
major: info.major,
minor: info.minor,
build: info.build,
revision,
})
}
/// `10.0.26200`, the kernel version `wmic os get Version` printed.
fn kernel(&self) -> String {
format!("{}.{}.{}", self.major, self.minor, self.build)
}
/// `10.0.26200.9711`, with the update revision when the registry has one.
fn release(&self) -> String {
match self.revision {
Some(r) => format!("{}.{r}", self.kernel()),
None => self.kernel(),
}
}
/// The line `ver` prints on an English host, which platform_os_version
/// and platform_snapshot have always answered. platform_snapshot's
/// fingerprint hashes it, so it is reproduced, not reworded.
fn ver_line(&self) -> String {
format!("Microsoft Windows [Version {}]", self.release())
}
}
fn bi_sys_kernel(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
#[cfg(target_os = "windows")]
{
// The release string, as `uname -r` gives one elsewhere. It was
// ver's whole sentence.
Ok(Value::Str(WindowsVersion::read("sys_kernel")?.release()))
}
#[cfg(not(target_os = "windows"))]
{
let mut cmd = crate::safety::child_command("uname");
cmd.args(["-r"]);
return Ok(Value::Str(probe_text("sys_kernel", "uname", cmd)?));
}
}
/// Whole seconds from an uptime figure such as `12345.67`.
///
/// Anything else (empty, text, NaN, negative) is refused. The old code read
/// it as 0 seconds, "up 0 seconds" on a host that had been up for days.
#[cfg(any(target_os = "linux", test))]
fn uptime_seconds(builtin: &str, text: &str) -> Result<i64> {
match text.trim().parse::<f64>() {
Ok(secs) if secs.is_finite() && secs >= 0.0 => Ok(secs as i64),
_ => Err(crate::safety::bad_state(
builtin,
&format!("uptime reported `{}`", text.trim()),
"a number of seconds since boot",
)),
}
}
fn bi_sys_uptime(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// Failure is an error. Every probe failing used to answer `Int(0)`,
// "up 0 seconds", which a caller could not tell from a fresh boot.
#[cfg(target_os = "windows")]
{
return Ok(Value::Int((windows_uptime_ms() / 1000) as i64));
}
#[cfg(target_os = "linux")]
{
const PATH: &str = "/proc/uptime";
let uptime = std::fs::read_to_string(PATH)
.map_err(|e| crate::safety::fs_error("sys_uptime", PATH, &e))?;
let secs_str = uptime.split_whitespace().next().unwrap_or("");
return uptime_seconds("sys_uptime", secs_str).map(Value::Int);
}
#[cfg(target_os = "macos")]
{
// macOS: sysctl kern.boottime returns "{ sec = 1234567890, usec = 0 }"
let mut cmd = crate::safety::child_command("sysctl");
cmd.args(["-n", "kern.boottime"]);
let output = bounded_cmd_output("sys_uptime", "sysctl", cmd, PROBE_DEADLINE_SECS)?;
let text = probe_stdout("sys_uptime", "sysctl -n kern.boottime", output)?;
// Parse "{ sec = 1234567890, usec = 0 }" or "sec = 1234567890"
let bad = || {
crate::safety::bad_state(
"sys_uptime",
&format!("kern.boottime reported `{}`", text.trim()),
"a `sec = N` boot time",
)
};
let sec_pos = text.find("sec = ").ok_or_else(|| bad())?;
let rest = &text[sec_pos + 6..];
let end = rest
.find(',')
.or_else(|| rest.find(' '))
.or_else(|| rest.find('}'))
.ok_or_else(|| bad())?;
let boot_sec = rest[..end].trim().parse::<i64>().map_err(|_| bad())?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
return Ok(Value::Int(now - boot_sec));
}
// Any other OS has no uptime source wired up here.
#[allow(unreachable_code)]
Err(crate::safety::unimplemented(
"sys_uptime",
"uptime on this OS",
"read the boot time with `sys_boot_time` where it is available",
))
}
/// Milliseconds since the Windows kernel started, sleep included.
///
/// sys_uptime and sys_boot_time had each started powershell to read WMI's
/// LastBootUpTime: about 12 s on an idle host and over the 30 s probe deadline
/// on a loaded one. The kernel's tick count answers at once. Measured on a
/// host with Fast Startup on, it put the boot 6 s before WMI did (WMI records
/// a moment later in startup); sleep and Fast Startup leave both alike.
#[cfg(target_os = "windows")]
fn windows_uptime_ms() -> u64 {
#[link(name = "kernel32")]
extern "system" {
fn GetTickCount64() -> u64;
}
// SAFETY: no arguments, no failure mode.
unsafe { GetTickCount64() }
}
/// The boot time as the one shape `sys_boot_time` answers in: UTC ISO 8601 to
/// the second, for example `2026-10-30T12:34:56Z`.
fn boot_time_value(epoch_secs: i64) -> Result<Value> {
utc_iso_seconds(epoch_secs).map(Value::Str).ok_or_else(|| {
crate::safety::bad_state(
"sys_boot_time",
&format!("the boot time {epoch_secs} is out of range"),
"a boot time within the calendar",
)
})
}
/// The boot time in seconds since the epoch from `sysctl -n kern.boottime`
/// text, `{ sec = 1234567890, usec = 0 }`. None when no `sec = N` is there.
#[cfg(target_os = "macos")]
fn kern_boottime_secs(text: &str) -> Option<i64> {
let rest = &text[text.find("sec = ")? + 6..];
let end = rest.find(|c: char| c == ',' || c == ' ' || c == '}')?;
rest[..end].trim().parse::<i64>().ok()
}
fn bi_sys_boot_time(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// Every arm answers through `boot_time_value`, so the shape is the same on
// every OS: UTC ISO 8601 to the second.
#[cfg(target_os = "windows")]
{
let now = chrono::Utc::now().timestamp();
return boot_time_value(now - (windows_uptime_ms() / 1000) as i64);
}
// `uptime -s` is a procps/GNU flag. macOS `uptime` does not have it, so
// this exited non-zero there and fell through to `Ok(Value::Null)` -- a
// silent failure on every Mac, which nothing noticed because nothing ran
// it. Declaring the signature made the example executable and CI failed on
// the first try.
#[cfg(target_os = "macos")]
{
let mut cmd = crate::safety::child_command("sysctl");
cmd.args(["-n", "kern.boottime"]);
let output = bounded_cmd_output("sys_boot_time", "sysctl", cmd, PROBE_DEADLINE_SECS)?;
let text = probe_stdout("sys_boot_time", "sysctl -n kern.boottime", output)?;
let secs = kern_boottime_secs(&text).ok_or_else(|| {
crate::safety::bad_state(
"sys_boot_time",
&format!("kern.boottime reported `{}`", text.trim()),
"a `sec = N` boot time",
)
})?;
return boot_time_value(secs);
}
// `uptime -s` prints the boot time in local time, to the second. It is read
// back in the local zone and converted to UTC.
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
{
let mut cmd = crate::safety::child_command("uptime");
cmd.args(["-s"]);
let output = bounded_cmd_output("sys_boot_time", "uptime", cmd, PROBE_DEADLINE_SECS)?;
let text = probe_stdout("sys_boot_time", "uptime -s", output)?;
let bad = || {
crate::safety::bad_state(
"sys_boot_time",
&format!("uptime -s reported `{}`", text.trim()),
"a `YYYY-MM-DD HH:MM:SS` local boot time",
)
};
let naive = chrono::NaiveDateTime::parse_from_str(text.trim(), "%Y-%m-%d %H:%M:%S")
.map_err(|_| bad())?;
let local = chrono::TimeZone::from_local_datetime(&chrono::Local, &naive)
.single()
.ok_or_else(bad)?;
return boot_time_value(local.timestamp());
}
// Every arm above returns. Were one to fall through, this is an error, not
// the silent `Null` the macOS arm once answered.
#[allow(unreachable_code)]
Err(crate::safety::unimplemented(
"sys_boot_time",
"the boot time on this OS",
"read the uptime with `sys_uptime` where it is available",
))
}
/// The CPU record from `/proc/cpuinfo` text.
///
/// A field the kernel does not print on this machine (ARM has no "model
/// name"; many VMs have no "cpu cores") is left out of the record. The old
/// code wrote 0 cores and 0 MHz in its place. A field that is printed but is
/// not a number is refused.
#[cfg(any(target_os = "linux", test))]
fn parse_cpuinfo(text: &str) -> Result<Value> {
let mut rec = std::collections::BTreeMap::new();
for line in text.lines() {
if line.starts_with("model name") {
if let Some(name) = line.split(':').nth(1) {
rec.insert("name".to_string(), Value::Str(name.trim().to_string()));
}
} else if line.starts_with("cpu cores") {
if let Some(cores) = line.split(':').nth(1) {
let n = cores.trim().parse::<i64>().map_err(|_| {
crate::safety::bad_state(
"sys_cpu_info",
&format!("cpu cores is `{}`", cores.trim()),
"a whole number of cores in /proc/cpuinfo",
)
})?;
rec.insert("cores".to_string(), Value::Int(n));
}
} else if line.starts_with("cpu MHz") {
if let Some(mhz) = line.split(':').nth(1) {
let f = mhz.trim().parse::<f64>().map_err(|_| {
crate::safety::bad_state(
"sys_cpu_info",
&format!("cpu MHz is `{}`", mhz.trim()),
"a clock speed in /proc/cpuinfo",
)
})?;
rec.insert("mhz".to_string(), Value::Float(f));
}
}
}
Ok(Value::Record(rec))
}
fn bi_sys_cpu_info(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// Returns consistent Record {name: String, cores: Int, mhz: Float} on all platforms
// Windows: was a PowerShell Get-CimInstance Win32_Processor query (WMI),
// parsed from its JSON. Now read in process: the name and physical core
// count through sysinfo, the clock from the registry's ~MHz value. A value
// that cannot be read is left out of the record.
#[cfg(target_os = "windows")]
{
let mut rec = std::collections::BTreeMap::new();
let sys = System::new_with_specifics(
sysinfo::RefreshKind::nothing().with_cpu(sysinfo::CpuRefreshKind::nothing()),
);
if let Some(cpu) = sys.cpus().first() {
let name = cpu.brand().trim();
if !name.is_empty() {
rec.insert("name".to_string(), Value::Str(name.to_string()));
}
}
if let Some(cores) = sys.physical_core_count() {
rec.insert("cores".to_string(), Value::Int(cores as i64));
}
if let Ok(Some(mhz)) = windows_cpu_mhz("sys_cpu_info") {
rec.insert("mhz".to_string(), Value::Float(mhz as f64));
}
return Ok(Value::Record(rec));
}
#[cfg(target_os = "linux")]
{
const PATH: &str = "/proc/cpuinfo";
let cpuinfo = std::fs::read_to_string(PATH)
.map_err(|e| crate::safety::fs_error("sys_cpu_info", PATH, &e))?;
return parse_cpuinfo(&cpuinfo);
}
#[cfg(target_os = "macos")]
{
let mut rec = std::collections::BTreeMap::new();
// CPU brand string
let mut brand = crate::safety::child_command("sysctl");
brand.args(["-n", "machdep.cpu.brand_string"]);
if let Ok(output) = bounded_cmd_output("sys_cpu_info", "sysctl", brand, PROBE_DEADLINE_SECS)
{
if output.status.success() {
rec.insert(
"name".to_string(),
Value::Str(String::from_utf8_lossy(&output.stdout).trim().to_string()),
);
}
}
// Core count
let mut physical = crate::safety::child_command("sysctl");
physical.args(["-n", "hw.physicalcpu"]);
if let Ok(output) =
bounded_cmd_output("sys_cpu_info", "sysctl", physical, PROBE_DEADLINE_SECS)
{
if output.status.success() {
// A count that does not parse was 0 cores before.
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
let cores = text.parse::<i64>().map_err(|_| {
crate::safety::bad_state(
"sys_cpu_info",
&format!("hw.physicalcpu reported `{text}`"),
"a whole number of physical cores",
)
})?;
rec.insert("cores".to_string(), Value::Int(cores));
}
}
// CPU frequency (MHz). `hw.cpufrequency` is missing on Apple Silicon,
// so a failed query leaves `mhz` out; a reading that is not a number
// is refused instead of read as 0 MHz.
let mut freq = crate::safety::child_command("sysctl");
freq.args(["-n", "hw.cpufrequency"]);
if let Ok(output) = bounded_cmd_output("sys_cpu_info", "sysctl", freq, PROBE_DEADLINE_SECS)
{
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
// Apple Silicon prints nothing for this key and exits 0: no
// frequency is reported, so the record leaves `mhz` out.
if !text.is_empty() {
let hz = text.parse::<f64>().map_err(|_| {
crate::safety::bad_state(
"sys_cpu_info",
&format!("hw.cpufrequency reported `{text}`"),
"a clock frequency in hertz",
)
})?;
rec.insert("mhz".to_string(), Value::Float(hz / 1_000_000.0));
}
}
}
if !rec.is_empty() {
return Ok(Value::Record(rec));
}
}
// Every machine has a CPU; reaching here means detection failed, which a
// null reported as an answer.
#[allow(unreachable_code)]
Err(crate::safety::tool_failed(
"sys_cpu_info",
"the platform's CPU query",
None,
"CPU information could not be read",
))
}
fn bi_sys_cpu_count(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
Ok(Value::Int(
std::thread::available_parallelism()
.map(|p| p.get() as i64)
.unwrap_or(1),
))
}
fn bi_sys_cpu_freq(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// Null means the OS does not expose a frequency: no cpufreq driver, or
// Apple Silicon. A source that exists and fails -- a registry value or a
// file that cannot be read, a reading that is not a number -- is a coded
// error. It used to read as Null, the same answer as "not exposed".
#[cfg(target_os = "windows")]
{
return Ok(
windows_cpu_mhz("sys_cpu_freq")?.map_or(Value::Null, |mhz| Value::Int(mhz as i64))
);
}
#[cfg(target_os = "linux")]
{
const PATH: &str = "/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq";
return match std::fs::read_to_string(PATH) {
// kHz to MHz
Ok(freq) => freq
.trim()
.parse::<i64>()
.map(|khz| Value::Int(khz / 1000))
.map_err(|_| {
crate::safety::bad_state(
"sys_cpu_freq",
&format!("{PATH} does not hold a frequency: `{}`", freq.trim()),
"a cpufreq driver that reports scaling_cur_freq in kHz",
)
}),
// No cpufreq driver (many VMs): the kernel does not say.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Value::Null),
Err(e) => Err(crate::safety::fs_error("sys_cpu_freq", PATH, &e)),
};
}
#[cfg(target_os = "macos")]
{
let mut cmd = crate::safety::child_command("sysctl");
cmd.args(["-n", "hw.cpufrequency"]);
let output = bounded_cmd_output("sys_cpu_freq", "sysctl", cmd, PROBE_DEADLINE_SECS)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
// Apple Silicon has no such OID: the OS does not expose a frequency.
if stderr.contains("unknown oid") {
return Ok(Value::Null);
}
return Err(crate::safety::tool_failed(
"sys_cpu_freq",
"sysctl -n hw.cpufrequency",
output.status.code(),
&stderr,
));
}
let text = String::from_utf8_lossy(&output.stdout);
let text = text.trim();
// Exit 0 with nothing printed is how Apple Silicon answers: the OS
// exposes no frequency, so this is a null, not a refusal.
if text.is_empty() {
return Ok(Value::Null);
}
// Hz to MHz
return match text.parse::<i64>() {
Ok(hz) => Ok(Value::Int(hz / 1_000_000)),
Err(_) => Err(crate::safety::bad_state(
"sys_cpu_freq",
&format!("sysctl reported no frequency: `{text}`"),
"a Mac whose hw.cpufrequency is an integer",
)),
};
}
// Nothing was read on this OS, so this is a refusal, not a null.
#[allow(unreachable_code)]
Err(crate::safety::unimplemented(
"sys_cpu_freq",
"CPU frequency is read on Windows, Linux and macOS only; NOTHING WAS INSPECTED",
"read the clock speed with the platform's own tools",
))
}
// Refresh only memory, not processes, CPUs, disks or networks.
fn memory_snapshot(builtin: &str) -> Result<System> {
if !sysinfo::IS_SUPPORTED_SYSTEM {
return Err(crate::safety::unimplemented(
builtin,
"memory queries are unsupported on this platform",
"run on a supported system",
));
}
let mut system = System::new();
system.refresh_memory();
if system.total_memory() == 0 {
return Err(crate::safety::tool_failed(
builtin,
"the system memory query",
None,
"total memory could not be read",
));
}
Ok(system)
}
fn memory_bytes(builtin: &str, bytes: u64) -> Result<Value> {
i64::try_from(bytes).map(Value::Int).map_err(|_| {
crate::safety::tool_failed(
builtin,
"the system memory query",
None,
"memory measurement exceeds Int",
)
})
}
fn bi_sys_mem_info(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let system = memory_snapshot("sys_mem_info")?;
Ok(Value::Record(BTreeMap::from([
(
"total".into(),
memory_bytes("sys_mem_info", system.total_memory())?,
),
(
"free".into(),
memory_bytes("sys_mem_info", system.free_memory())?,
),
(
"available".into(),
memory_bytes("sys_mem_info", system.available_memory())?,
),
])))
}
fn bi_sys_swap_info(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let system = memory_snapshot("sys_swap_info")?;
Ok(Value::Record(BTreeMap::from([
(
"total".into(),
memory_bytes("sys_swap_info", system.total_swap())?,
),
(
"used".into(),
memory_bytes("sys_swap_info", system.used_swap())?,
),
(
"free".into(),
memory_bytes("sys_swap_info", system.free_swap())?,
),
])))
}
fn bi_sys_disk_info(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
bi_fs_df(vec![], None)
}
/// The `{1min, 5min, 15min}` record from the first three fields of a load
/// average reading. A short reading, or a field that is not a number, is
/// refused: the old code wrote 0.0 for it, a machine with no load.
#[cfg(any(target_os = "linux", target_os = "macos", test))]
fn load_average_record(builtin: &str, parts: &[&str]) -> Result<Value> {
if parts.len() < 3 {
return Err(crate::safety::bad_state(
builtin,
&format!("load average had {} fields", parts.len()),
"three load averages (1, 5 and 15 minutes)",
));
}
let mut rec = std::collections::BTreeMap::new();
for (key, text) in ["1min", "5min", "15min"].iter().zip(parts.iter()) {
let load = text.trim().parse::<f64>().map_err(|_| {
crate::safety::bad_state(
builtin,
&format!("load average `{text}` is not a number"),
"a decimal load average",
)
})?;
rec.insert(key.to_string(), Value::Float(load));
}
Ok(Value::Record(rec))
}
/// Sets `load_1`, `load_5` and `load_15` on a system record. Windows has no
/// load average, so there they are Null. sysinfo's Windows figure is a smoothed
/// CPU queue length that reads 0.0 until its first 5 second sample, which a
/// one-shot call never reaches, so it is not used there.
fn load_fields(rec: &mut BTreeMap<String, Value>) {
let sampled = if cfg!(windows) {
None
} else {
let load = System::load_average();
Some([load.one, load.five, load.fifteen])
};
for (key, value) in ["load_1", "load_5", "load_15"]
.into_iter()
.zip(load_field_values(sampled))
{
rec.insert(key.to_string(), value);
}
}
/// The pure half of `load_fields`: a sampled `[1, 5, 15]` load average becomes
/// three Floats, and no sample (Windows) becomes three Nulls.
fn load_field_values(sampled: Option<[f64; 3]>) -> [Value; 3] {
match sampled {
Some(loads) => loads.map(Value::Float),
None => [Value::Null, Value::Null, Value::Null],
}
}
fn bi_sys_load_avg(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// Record {1min: Float, 5min: Float, 15min: Float} on Linux and macOS. Windows
// has no load average and is refused.
#[cfg(target_os = "linux")]
{
const PATH: &str = "/proc/loadavg";
let loadavg = std::fs::read_to_string(PATH)
.map_err(|e| crate::safety::fs_error("sys_load_avg", PATH, &e))?;
let parts: Vec<&str> = loadavg.split_whitespace().collect();
return load_average_record("sys_load_avg", &parts);
}
#[cfg(target_os = "macos")]
{
// macOS: sysctl vm.loadavg returns "{ 1.23 4.56 7.89 }"
let mut loadavg = crate::safety::child_command("sysctl");
loadavg.args(["-n", "vm.loadavg"]);
let output = bounded_cmd_output("sys_load_avg", "sysctl", loadavg, PROBE_DEADLINE_SECS)?;
let text = probe_stdout("sys_load_avg", "sysctl -n vm.loadavg", output)?;
let parts: Vec<&str> = text
.trim()
.trim_matches(|c| c == '{' || c == '}')
.split_whitespace()
.collect();
return load_average_record("sys_load_avg", &parts);
}
#[cfg(target_os = "windows")]
{
// Windows has no load average. The processor load percentage is not a
// substitute (it is not a run-queue average), so none is reported.
return Err(crate::safety::unimplemented(
"sys_load_avg",
"Windows has no load average",
"read the current processor load with `sys_cpu_info` or the busiest processes with `proc_list`",
));
}
// Any other OS has no load-average source wired up here.
#[allow(unreachable_code)]
Err(crate::safety::unimplemented(
"sys_load_avg",
"load average on this OS",
"this OS exposes no load average to read",
))
}
/// A numeric uid or gid field from `/etc/passwd` or `/etc/group`. A field
/// that is not a number is refused: the old `unwrap_or(0)` read it as root.
#[cfg(any(not(target_os = "windows"), test))]
fn account_id(builtin: &str, text: &str) -> Result<i64> {
text.trim().parse::<i64>().map_err(|_| {
crate::safety::bad_state(
builtin,
&format!("account id `{text}` is not a number"),
"numeric uid and gid fields in the account file",
)
})
}
/// The account records from `/etc/passwd` text. Comment and short lines are
/// skipped, as before.
#[cfg(any(not(target_os = "windows"), test))]
fn parse_passwd(text: &str) -> Result<Vec<Value>> {
text.lines()
.filter(|l| !l.starts_with('#') && l.split(':').count() >= 7)
.map(|l| -> Result<Value> {
let parts: Vec<&str> = l.split(':').collect();
let mut rec = std::collections::BTreeMap::new();
rec.insert("name".to_string(), Value::Str(parts[0].to_string()));
rec.insert(
"uid".to_string(),
Value::Int(account_id("sys_users", parts[2])?),
);
rec.insert(
"gid".to_string(),
Value::Int(account_id("sys_users", parts[3])?),
);
rec.insert("home".to_string(), Value::Str(parts[5].to_string()));
rec.insert("shell".to_string(), Value::Str(parts[6].to_string()));
Ok(Value::Record(rec))
})
.collect()
}
/// The group records from `/etc/group` text. Comment and short lines are
/// skipped, as before.
#[cfg(any(not(target_os = "windows"), test))]
fn parse_group(text: &str) -> Result<Vec<Value>> {
text.lines()
.filter(|l| !l.starts_with('#') && l.split(':').count() >= 4)
.map(|l| -> Result<Value> {
let parts: Vec<&str> = l.split(':').collect();
let mut rec = std::collections::BTreeMap::new();
rec.insert("name".to_string(), Value::Str(parts[0].to_string()));
rec.insert(
"gid".to_string(),
Value::Int(account_id("sys_groups", parts[2])?),
);
rec.insert("members".to_string(), Value::Str(parts[3].to_string()));
Ok(Value::Record(rec))
})
.collect()
}
#[cfg(test)]
mod quiet_sys_tests {
use super::*;
fn field<'a>(v: &'a Value, key: &str) -> Option<&'a Value> {
match v {
Value::Record(r) => r.get(key),
_ => None,
}
}
#[test]
fn uptime_reads_whole_seconds() {
assert_eq!(uptime_seconds("sys_uptime", "12345.67\n").unwrap(), 12345);
}
#[test]
fn uptime_refuses_bad_figures() {
assert!(uptime_seconds("sys_uptime", "").is_err());
assert!(uptime_seconds("sys_uptime", "soon").is_err());
assert!(uptime_seconds("sys_uptime", "NaN").is_err());
assert!(uptime_seconds("sys_uptime", "-4").is_err());
}
#[cfg(target_os = "windows")]
#[test]
fn windows_boot_time_and_uptime_agree_without_a_process() {
let Value::Str(boot) = bi_sys_boot_time(vec![], None).unwrap() else {
panic!("sys_boot_time is not a String");
};
let boot = chrono::DateTime::parse_from_rfc3339(&boot).expect("an RFC 3339 time");
let Value::Int(up) = bi_sys_uptime(vec![], None).unwrap() else {
panic!("sys_uptime is not an Int");
};
let now = chrono::Local::now().fixed_offset();
assert!(boot < now && up > 0);
let gap = ((now - boot).num_seconds() - up).abs();
assert!(gap <= 2, "boot time and uptime disagree by {gap} s");
}
#[cfg(target_os = "windows")]
#[test]
fn windows_kernel_clock_and_elevation_come_from_the_os_itself() {
let Value::Str(release) = bi_sys_kernel(vec![], None).unwrap() else {
panic!("sys_kernel is not a String");
};
let parts: Vec<&str> = release.split('.').collect();
assert!(
(3..=4).contains(&parts.len()) && parts.iter().all(|p| p.parse::<u32>().is_ok()),
"not a version: {release}"
);
assert!(matches!(
bi_sys_cpu_freq(vec![], None).unwrap(),
Value::Null | Value::Int(1..)
));
// Either answer is right; the token must be readable.
windows_is_admin("sudo_check").unwrap();
// An absent value is None, not an error.
assert_eq!(
windows_hklm_dword(
r"SOFTWARE\Microsoft\Windows NT\CurrentVersion",
"NoSuchValue"
),
Ok(None)
);
// A REG_SZ reads whole, and an absent one is None.
let zone = windows_hklm_string(
r"SYSTEM\CurrentControlSet\Control\TimeZoneInformation",
"TimeZoneKeyName",
);
assert!(matches!(&zone, Ok(Some(z)) if !z.is_empty()), "{zone:?}");
// A buffer too small for it grows rather than truncating.
assert_eq!(
windows_hklm_string_from(
r"SYSTEM\CurrentControlSet\Control\TimeZoneInformation",
"TimeZoneKeyName",
1,
),
zone
);
assert_eq!(
windows_hklm_string(
r"SOFTWARE\Microsoft\Windows NT\CurrentVersion",
"NoSuchValue"
),
Ok(None)
);
assert!(matches!(bi_sys_locale(vec![], None).unwrap(), Value::Str(l) if !l.is_empty()));
// The adapter table has loopback, which sysinfo's interface list left
// out, with its IPv4 address.
let adapters = windows_adapters("net_ip").unwrap();
assert!(adapters.iter().any(|a| a.up
&& a.addresses
.contains(&std::net::IpAddr::from([127, 0, 0, 1]))));
// The system drive answers with sizes and a file system.
let system = std::env::var("SystemDrive").unwrap_or_else(|_| "C:".into());
let volumes = windows_volumes("fs_df").unwrap();
let sys = volumes
.iter()
.find(|v| system.starts_with(v.letter))
.expect("the system drive is listed");
assert!(matches!((sys.total, sys.free), (Some(t), Some(f)) if t > 0 && f <= t));
assert!(sys.fs_name.as_deref().is_some_and(|n| !n.is_empty()));
// This process: its own threads, CPU time and priority, no PowerShell.
let me = vec![Value::Int(std::process::id() as i64)];
assert!(matches!(bi_proc_threads(me.clone(), None).unwrap(), Value::Int(n) if n >= 1));
assert!(
matches!(bi_proc_cpu_usage(me.clone(), None).unwrap(), Value::Float(s) if s >= 0.0)
);
bi_proc_priority(me, None).unwrap();
// One record per interface, not ipconfig's text in a `raw` field.
let Value::Array(nics) = bi_platform_network_interfaces(vec![], None).unwrap() else {
panic!("platform_network_interfaces is not an Array");
};
assert!(!nics.is_empty());
for nic in &nics {
assert!(field(nic, "name").is_some() && field(nic, "raw").is_none());
}
}
#[test]
fn load_average_reads_three_fields() {
let v = load_average_record("sys_load_avg", &["0.10", "0.20", "0.30", "1/200"]).unwrap();
assert!(matches!(field(&v, "5min"), Some(Value::Float(f)) if (*f - 0.2).abs() < 1e-9));
assert!(matches!(field(&v, "15min"), Some(Value::Float(f)) if (*f - 0.3).abs() < 1e-9));
}
#[test]
fn load_average_refuses_short_or_bad_readings() {
assert!(load_average_record("sys_load_avg", &["0.1", "0.2"]).is_err());
assert!(load_average_record("sys_load_avg", &["0.1", "busy", "0.3"]).is_err());
}
#[test]
fn cpuinfo_leaves_absent_fields_out() {
// ARM /proc/cpuinfo: no model name, no cpu cores, no cpu MHz.
let v = parse_cpuinfo("processor\t: 0\nBogoMIPS\t: 50.00\n").unwrap();
assert!(field(&v, "name").is_none());
assert!(field(&v, "cores").is_none());
assert!(field(&v, "mhz").is_none());
}
#[test]
fn cpuinfo_reads_x86_fields() {
let v = parse_cpuinfo("model name\t: Intel Xeon\ncpu cores\t: 4\ncpu MHz\t\t: 2394.2\n")
.unwrap();
assert!(matches!(field(&v, "cores"), Some(Value::Int(4))));
assert!(matches!(field(&v, "mhz"), Some(Value::Float(f)) if (*f - 2394.2).abs() < 1e-6));
}
#[test]
fn cpuinfo_refuses_non_numeric_fields() {
assert!(parse_cpuinfo("cpu cores\t: many\n").is_err());
assert!(parse_cpuinfo("cpu MHz\t: fast\n").is_err());
}
#[test]
fn passwd_reads_records_and_skips_comments() {
let users = parse_passwd("# comment\nroot:x:0:0:root:/root:/bin/bash\nshort:x\n").unwrap();
assert_eq!(users.len(), 1);
assert!(matches!(field(&users[0], "uid"), Some(Value::Int(0))));
assert!(matches!(field(&users[0], "gid"), Some(Value::Int(0))));
}
#[test]
fn passwd_refuses_non_numeric_ids() {
assert!(parse_passwd("bad:x:abc:0:::/bin/sh\n").is_err());
assert!(parse_passwd("bad:x:1000:,:::/bin/sh\n").is_err());
}
#[test]
fn group_reads_records() {
let groups = parse_group("root:x:0:\nusers:x:100:alice,bob\n").unwrap();
assert_eq!(groups.len(), 2);
assert!(matches!(field(&groups[1], "gid"), Some(Value::Int(100))));
}
#[test]
fn group_refuses_non_numeric_gid() {
assert!(parse_group("odd:x:zz:\n").is_err());
}
}
fn bi_sys_users(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
#[cfg(target_os = "windows")]
{
let mut cmd = crate::safety::child_command("powershell");
cmd.args([
"-Command",
"Get-LocalUser | Select-Object Name,Enabled,LastLogon | ConvertTo-Json",
]);
let output = bounded_cmd_output("sys_users", "powershell", cmd, PROBE_DEADLINE_SECS)?;
let json_str = probe_stdout("sys_users", "powershell Get-LocalUser", output)?;
// Output that is not a list of accounts was [] before: "no users".
let json: serde_json::Value = serde_json::from_str(&json_str).map_err(|_| {
crate::safety::bad_state(
"sys_users",
"Get-LocalUser printed text that is not JSON",
"the ConvertTo-Json list of local accounts",
)
})?;
let items = match &json {
serde_json::Value::Array(arr) => arr.clone(),
obj @ serde_json::Value::Object(_) => vec![obj.clone()],
_ => {
return Err(crate::safety::bad_state(
"sys_users",
"Get-LocalUser printed neither a list nor an account",
"the ConvertTo-Json list of local accounts",
))
}
};
let users: Vec<Value> = items
.iter()
.filter_map(|obj| {
let o = obj.as_object()?;
let mut rec = std::collections::BTreeMap::new();
if let Some(v) = o.get("Name") {
rec.insert("name".to_string(), json_to_value(v.clone()));
}
if let Some(v) = o.get("Enabled") {
rec.insert("enabled".to_string(), json_to_value(v.clone()));
}
if let Some(v) = o.get("LastLogon") {
rec.insert("last_logon".to_string(), json_to_value(v.clone()));
}
Some(Value::Record(rec))
})
.collect();
return Ok(Value::Array(users));
}
#[cfg(not(target_os = "windows"))]
{
const PATH: &str = "/etc/passwd";
let passwd = std::fs::read_to_string(PATH)
.map_err(|e| crate::safety::fs_error("sys_users", PATH, &e))?;
return Ok(Value::Array(parse_passwd(&passwd)?));
}
// Unreachable: each platform arm above returns.
#[allow(unreachable_code)]
Err(crate::safety::tool_failed(
"sys_users",
"the account database",
None,
"it could not be read",
))
}
fn bi_sys_groups(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
#[cfg(target_os = "windows")]
{
let mut cmd = crate::safety::child_command("powershell");
cmd.args([
"-Command",
"Get-LocalGroup | Select-Object Name,Description | ConvertTo-Json",
]);
let output = bounded_cmd_output("sys_groups", "powershell", cmd, PROBE_DEADLINE_SECS)?;
let json_str = probe_stdout("sys_groups", "powershell Get-LocalGroup", output)?;
let json: serde_json::Value = serde_json::from_str(&json_str).map_err(|_| {
crate::safety::bad_state(
"sys_groups",
"Get-LocalGroup printed text that is not JSON",
"the ConvertTo-Json list of local groups",
)
})?;
let items = match &json {
serde_json::Value::Array(arr) => arr.clone(),
obj @ serde_json::Value::Object(_) => vec![obj.clone()],
_ => {
return Err(crate::safety::bad_state(
"sys_groups",
"Get-LocalGroup printed neither a list nor a group",
"the ConvertTo-Json list of local groups",
))
}
};
let groups: Vec<Value> = items
.iter()
.filter_map(|obj| {
let o = obj.as_object()?;
let mut rec = std::collections::BTreeMap::new();
if let Some(v) = o.get("Name") {
rec.insert("name".to_string(), json_to_value(v.clone()));
}
if let Some(v) = o.get("Description") {
rec.insert("description".to_string(), json_to_value(v.clone()));
}
Some(Value::Record(rec))
})
.collect();
return Ok(Value::Array(groups));
}
#[cfg(not(target_os = "windows"))]
{
const PATH: &str = "/etc/group";
let group = std::fs::read_to_string(PATH)
.map_err(|e| crate::safety::fs_error("sys_groups", PATH, &e))?;
return Ok(Value::Array(parse_group(&group)?));
}
// Unreachable: each platform arm above returns.
#[allow(unreachable_code)]
Err(crate::safety::tool_failed(
"sys_groups",
"the group database",
None,
"it could not be read",
))
}
/// `whoami()` -- the name of the user this shell runs as.
///
/// It was an alias of `sys_users`, so it returned every account on the
/// machine: root, daemon and the rest on Linux, every local user on Windows.
fn bi_whoami(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
#[cfg(unix)]
{
// The effective uid, as whoami(1) reports it. $USER can be unset
// (cron, containers) or stale (after su).
let uid = unsafe { libc::geteuid() };
let mut pwd: libc::passwd = unsafe { std::mem::zeroed() };
let mut buf = vec![0 as libc::c_char; 16 * 1024];
let mut found: *mut libc::passwd = std::ptr::null_mut();
let rc =
unsafe { libc::getpwuid_r(uid, &mut pwd, buf.as_mut_ptr(), buf.len(), &mut found) };
if rc == 0 && !found.is_null() && !pwd.pw_name.is_null() {
let name = unsafe { std::ffi::CStr::from_ptr(pwd.pw_name) };
return Ok(Value::Str(name.to_string_lossy().into_owned()));
}
return Err(crate::safety::bad_state(
"whoami",
&format!("uid {uid} has no entry in the user database"),
"the process runs as a uid with no passwd entry (common in containers)",
));
}
#[cfg(windows)]
{
return match std::env::var("USERNAME") {
Ok(u) if !u.is_empty() => Ok(Value::Str(u)),
_ => Err(crate::safety::bad_state(
"whoami",
"USERNAME is not set",
"this process was started without a user environment",
)),
};
}
#[allow(unreachable_code)]
Err(crate::safety::unimplemented(
"whoami",
"no user database on this platform; NOTHING WAS INSPECTED",
"",
))
}
fn bi_sys_user_info(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// Returns consistent Record {name, home, shell, domain} on all platforms
let mut rec = std::collections::BTreeMap::new();
#[cfg(target_os = "windows")]
{
if let Ok(user) = std::env::var("USERNAME") {
rec.insert("name".to_string(), Value::Str(user));
}
if let Ok(domain) = std::env::var("USERDOMAIN") {
rec.insert("domain".to_string(), Value::Str(domain));
}
if let Ok(profile) = std::env::var("USERPROFILE") {
rec.insert("home".to_string(), Value::Str(profile));
}
// Detect default shell on Windows
if let Ok(comspec) = std::env::var("COMSPEC") {
rec.insert("shell".to_string(), Value::Str(comspec));
} else {
rec.insert("shell".to_string(), Value::Str("cmd.exe".to_string()));
}
}
#[cfg(not(target_os = "windows"))]
{
if let Ok(user) = std::env::var("USER") {
rec.insert("name".to_string(), Value::Str(user));
}
if let Ok(home) = std::env::var("HOME") {
rec.insert("home".to_string(), Value::Str(home));
}
if let Ok(shell) = std::env::var("SHELL") {
rec.insert("shell".to_string(), Value::Str(shell));
}
rec.insert("domain".to_string(), Value::Str("local".to_string()));
}
Ok(Value::Record(rec))
}
fn bi_sys_env_all(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut rec = std::collections::BTreeMap::new();
for (key, val) in std::env::vars() {
let gated = gate_env_secret(&key, val);
rec.insert(key, gated);
}
Ok(Value::Record(rec))
}
fn bi_sys_locale(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
#[cfg(target_os = "windows")]
{
// The user's locale, as `(Get-Culture).Name` gave it, without
// starting PowerShell to ask.
#[link(name = "kernel32")]
extern "system" {
fn GetUserDefaultLocaleName(name: *mut u16, len: i32) -> i32;
}
// LOCALE_NAME_MAX_LENGTH, NUL included.
let mut buf = [0u16; 85];
// SAFETY: `buf` holds `len` UTF-16 units.
let n = unsafe { GetUserDefaultLocaleName(buf.as_mut_ptr(), buf.len() as i32) };
if n > 1 {
return Ok(Value::Str(String::from_utf16_lossy(&buf[..n as usize - 1])));
}
}
#[cfg(not(target_os = "windows"))]
{
if let Ok(lang) = std::env::var("LANG") {
return Ok(Value::Str(lang));
}
}
Ok(Value::Str("C".to_string()))
}
fn bi_sys_timezone(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
#[cfg(target_os = "windows")]
{
// The zone ID `(Get-TimeZone).Id` gave (`Pacific Standard Time`),
// read from where Windows keeps it instead of from PowerShell. A
// failed read used to fall through to "UTC".
return match windows_hklm_string(
r"SYSTEM\CurrentControlSet\Control\TimeZoneInformation",
"TimeZoneKeyName",
) {
Ok(Some(zone)) if !zone.is_empty() => Ok(Value::Str(zone)),
Ok(_) => Err(crate::safety::not_found(
"sys_timezone",
"registry value",
r"HKLM\SYSTEM\CurrentControlSet\Control\TimeZoneInformation\TimeZoneKeyName",
)),
Err(code) => Err(crate::safety::io_fault(
"sys_timezone",
&format!("reading the time zone from the registry failed (error {code})"),
"the zone is unknown, not UTC; retry, or read Get-TimeZone",
)),
};
}
#[cfg(not(target_os = "windows"))]
{
if let Ok(tz) = std::env::var("TZ") {
return Ok(Value::Str(tz));
}
if let Ok(tz) = std::fs::read_to_string("/etc/timezone") {
return Ok(Value::Str(tz.trim().to_string()));
}
// macOS has no /etc/timezone, so this fell through to the "UTC"
// default below: a Mac in Los Angeles reported UTC, confidently and
// wrongly. The zone name is the tail of what /etc/localtime points
// at, which is also how Linux distros that ship no /etc/timezone
// record it.
if let Ok(target) = std::fs::read_link("/etc/localtime") {
let path = target.to_string_lossy();
if let Some(i) = path.find("zoneinfo/") {
let zone = &path[i + "zoneinfo/".len()..];
if !zone.is_empty() {
return Ok(Value::Str(zone.to_string()));
}
}
}
// With no /etc/localtime, glibc and musl use UTC, so UTC is the
// answer. One that exists but names no zone (a copied tzfile, as in
// many containers) was reported as UTC whatever it held.
match std::fs::symlink_metadata("/etc/localtime") {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Value::Str("UTC".to_string())),
_ => Err(crate::safety::unimplemented(
"sys_timezone",
"naming the zone when TZ is unset and /etc/localtime is not a link into zoneinfo",
"set TZ to the zone name",
)),
}
}
}
// ============================================================================
// 310-329: Service & Daemon Management
// ============================================================================
/// Parse the `Get-Service | ConvertTo-Json` output into service records.
///
/// Empty output from a successful probe is a host with no services, so it is
/// `[]`. Anything else that does not parse, is neither an array nor an object,
/// or holds an item without a `Name` is refused. Dropping such an item would
/// hand back a shorter list that reads as "no such service".
#[cfg(any(windows, test))]
fn win_service_records(builtin: &str, stdout: &str) -> Result<Vec<Value>> {
let text = stdout.trim();
if text.is_empty() {
return Ok(vec![]);
}
let json: serde_json::Value =
serde_json::from_str(text).map_err(|_| net_unparsed(builtin, "powershell"))?;
let items = match json {
serde_json::Value::Array(arr) => arr,
obj @ serde_json::Value::Object(_) => vec![obj],
_ => return Err(net_unparsed(builtin, "powershell")),
};
items
.into_iter()
.map(|item| {
let o = match item {
serde_json::Value::Object(o) => o,
_ => return Err(net_unparsed(builtin, "powershell")),
};
let name = o
.get("Name")
.cloned()
.ok_or_else(|| net_unparsed(builtin, "powershell"))?;
let mut rec = std::collections::BTreeMap::new();
rec.insert("name".to_string(), json_to_value(name));
if let Some(v) = o.get("DisplayName") {
rec.insert("display_name".to_string(), json_to_value(v.clone()));
}
let status_str = match o.get("Status").and_then(|v| v.as_i64()) {
Some(1) => "stopped",
Some(4) => "running",
Some(2) => "start_pending",
Some(3) => "stop_pending",
_ => "unknown",
};
rec.insert("status".to_string(), Value::Str(status_str.to_string()));
Ok(Value::Record(rec))
})
.collect()
}
/// Check `systemctl list-units --output=json` output and pass it through in
/// the shape it already had. Anything that is not a JSON array of objects,
/// including empty output, is refused rather than read as "no services".
#[cfg(any(target_os = "linux", test))]
fn systemd_units(builtin: &str, stdout: &str) -> Result<Value> {
let json: serde_json::Value =
serde_json::from_str(stdout.trim()).map_err(|_| net_unparsed(builtin, "systemctl"))?;
let ok = matches!(
&json,
serde_json::Value::Array(items) if items.iter().all(|i| i.is_object())
);
if !ok {
return Err(net_unparsed(builtin, "systemctl"));
}
Ok(json_to_value(json))
}
fn bi_svc_list(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
#[cfg(target_os = "windows")]
{
let mut cmd = crate::safety::child_command("powershell");
cmd.args([
"-Command",
"Get-Service | Select-Object Name,DisplayName,Status | ConvertTo-Json",
]);
let output = bounded_cmd_output("svc_list", "powershell", cmd, PROBE_DEADLINE_SECS)?;
if !output.status.success() {
return Err(crate::safety::tool_failed(
"svc_list",
"Get-Service",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
let text = String::from_utf8_lossy(&output.stdout);
return win_service_records("svc_list", &text).map(Value::Array);
}
#[cfg(target_os = "linux")]
{
let mut cmd = crate::safety::child_command("systemctl");
cmd.args([
"list-units",
"--type=service",
"--no-pager",
"--output=json",
]);
let output = bounded_cmd_output("svc_list", "systemctl", cmd, PROBE_DEADLINE_SECS)?;
// Old systemd (before --output=json) exits non-zero here; that is a
// tool failure, not an empty list.
if !output.status.success() {
return Err(crate::safety::tool_failed(
"svc_list",
"systemctl list-units",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
let text = String::from_utf8_lossy(&output.stdout);
return systemd_units("svc_list", &text);
}
#[cfg(target_os = "macos")]
{
let mut cmd = crate::safety::child_command("launchctl");
cmd.arg("list");
// A failing launchctl used to answer an empty service list, which reads
// as "no services". It is an error now.
let text = probe_text("svc_list", "launchctl", cmd)?;
// Format: PID\tStatus\tLabel
let services: Vec<Value> = text
.lines()
.skip(1)
.filter_map(|line| {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 3 {
let mut rec = std::collections::BTreeMap::new();
rec.insert("name".to_string(), Value::Str(parts[2].to_string()));
let status = if parts[0] == "-" {
"stopped"
} else {
"running"
};
rec.insert("status".to_string(), Value::Str(status.to_string()));
Some(Value::Record(rec))
} else {
None
}
})
.collect();
return Ok(Value::Array(services));
}
#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
{
Err(crate::safety::unimplemented(
"svc_list",
"listing services is not implemented on this platform",
"there is no service-manager probe for this platform",
))
}
}
fn bi_svc_status(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let name = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"svc_status",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
crate::safety::reject_option_like("svc_status", std::slice::from_ref(&name))?;
#[cfg(target_os = "windows")]
{
let mut cmd = crate::safety::child_command("powershell");
cmd
.args([
"-Command",
// A missing service exits 3, distinct from every other failure.
&crate::ps_script!(
"try {{ Get-Service -Name {} -ErrorAction Stop | Select-Object Name,Status | ConvertTo-Json }} catch {{ if ($_.FullyQualifiedErrorId -like 'NoServiceFoundForGivenName*') {{ exit 3 }} else {{ throw }} }}",
crate::safety::ps_quote(&name)
),
]);
let output = bounded_cmd_output("svc_status", "powershell", cmd, PROBE_DEADLINE_SECS)?;
if output.status.success() {
let json_str = String::from_utf8_lossy(&output.stdout);
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&json_str) {
if let Some(obj) = json.as_object() {
let mut rec = std::collections::BTreeMap::new();
if let Some(v) = obj.get("Name") {
rec.insert("name".to_string(), json_to_value(v.clone()));
}
let status_str = match obj.get("Status").and_then(|v| v.as_i64()) {
Some(1) => "stopped",
Some(2) => "start_pending",
Some(3) => "stop_pending",
Some(4) => "running",
Some(5) => "continue_pending",
Some(6) => "pause_pending",
Some(7) => "paused",
_ => "unknown",
};
rec.insert("status".to_string(), Value::Str(status_str.to_string()));
return Ok(Value::Record(rec));
}
}
}
// A service that does not exist fell through to null, then to a tool
// failure; the declaration promises E_NOT_FOUND, as on Linux.
if output.status.code() == Some(3) {
return Err(crate::safety::not_found("svc_status", "service", &name));
}
return Err(crate::safety::tool_failed(
"svc_status",
"Get-Service",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
#[cfg(target_os = "linux")]
{
// `systemctl is-active` says "inactive" for a unit that does not
// exist, so a missing service read as a stopped one. LoadState tells
// them apart.
let mut cmd = crate::safety::child_command("systemctl");
cmd.args([
"show",
"-p",
"LoadState",
"-p",
"ActiveState",
"-p",
"SubState",
&name,
]);
let output = bounded_cmd_output("svc_status", "systemctl", cmd, PROBE_DEADLINE_SECS)?;
if !output.status.success() {
return Err(crate::safety::tool_failed(
"svc_status",
"systemctl show",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
let text = String::from_utf8_lossy(&output.stdout).to_string();
let prop = |k: &str| {
text.lines()
.find_map(|l| l.strip_prefix(&format!("{k}=")))
.unwrap_or("")
.to_string()
};
if prop("LoadState") == "not-found" {
return Err(crate::safety::not_found("svc_status", "service", &name));
}
// A loaded unit always reports its ActiveState; an empty one would
// answer "" as a status.
if prop("ActiveState").is_empty() {
return Err(crate::safety::bad_state(
"svc_status",
&format!("systemctl reported no ActiveState for `{name}`"),
"a systemd that reports ActiveState for every unit it loads",
));
}
let mut rec = std::collections::BTreeMap::new();
rec.insert("name".to_string(), Value::Str(name.clone()));
rec.insert("status".to_string(), Value::Str(prop("ActiveState")));
rec.insert("sub_state".to_string(), Value::Str(prop("SubState")));
return Ok(Value::Record(rec));
}
#[cfg(target_os = "macos")]
{
let mut cmd = crate::safety::child_command("launchctl");
cmd.args(["list", &name]);
let output = bounded_cmd_output("svc_status", "launchctl", cmd, PROBE_DEADLINE_SECS)?;
// `launchctl list <label>` fails for a label that is not loaded, which
// read as "stopped", and succeeds for a loaded job whether or not it
// is running, which read as "running". A running job has a PID.
if !output.status.success() {
return Err(crate::safety::not_found(
"svc_status",
"loaded service",
&name,
));
}
let text = String::from_utf8_lossy(&output.stdout);
let mut rec = std::collections::BTreeMap::new();
rec.insert("name".to_string(), Value::Str(name.clone()));
let running = text.contains("\"PID\" =");
rec.insert(
"status".to_string(),
Value::Str(if running { "running" } else { "stopped" }.to_string()),
);
return Ok(Value::Record(rec));
}
#[allow(unreachable_code)]
Err(crate::safety::unimplemented(
"svc_status",
"service status is not implemented on this OS; NOTHING WAS INSPECTED",
"query the service manager directly",
))
}
/// The service name `svc_start` and its siblings take: letters, digits and
/// `-_.@:`, not beginning with `-`. Systemd units (`getty@tty1.service`),
/// launchd labels (`com.example.agent`) and Windows service names all fit.
fn svc_name(builtin: &str, args: &[Value], input: &Option<Value>) -> Result<String> {
refuse_piped(builtin, input)?;
k8s_max_args(builtin, args, 1)?;
match args.first() {
Some(Value::Str(s))
if !s.is_empty()
&& !s.starts_with('-')
&& s.len() <= 256
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || "-_.@:".contains(c)) =>
{
Ok(s.clone())
}
Some(Value::Str(s)) => Err(crate::safety::bad_arg(
builtin,
"a service name of letters, digits and -_.@: not beginning with -",
s,
)),
other => Err(crate::safety::bad_arg(
builtin,
"a service name String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
)),
}
}
/// Run one service-manager command and turn a nonzero exit into E_TOOL_FAILED.
fn svc_run(builtin: &str, program: &str, argv: &[&str]) -> Result<()> {
let run = tool_run(
builtin,
program,
None,
argv,
Keep::Tail(TOOL_STDERR_TAIL),
TOOL_DEADLINE_SECS,
)?;
if !run.success {
return Err(crate::safety::tool_failed(
builtin,
&run.command,
run.code,
&run.stderr,
));
}
Ok(())
}
/// Start, stop, restart, enable or disable one service through the platform
/// service manager: `systemctl` on Linux, `launchctl` on macOS, and the
/// PowerShell service cmdlets on Windows (the name single-quoted by
/// `ps_quote`). The result is {name, action}. A refusal by the manager (no such
/// service, not permitted) is E_TOOL_FAILED; another OS is E_UNIMPLEMENTED.
///
/// Each of these used to answer `true` or `false` as a value, so an unknown
/// service or a denied request read as an ordinary result; to drop surplus
/// arguments and pipeline input; and to run with no deadline. On an
/// unsupported OS they answered `false` without trying.
fn svc_action(
builtin: &str,
action: &str,
args: Vec<Value>,
input: Option<Value>,
) -> Result<Value> {
let name = svc_name(builtin, &args, &input)?;
if cfg!(windows) {
let quoted = crate::safety::ps_quote(&name);
let script = match action {
"start" => crate::ps_script!("Start-Service {} -ErrorAction Stop", quoted),
"stop" => crate::ps_script!("Stop-Service {} -ErrorAction Stop", quoted),
"restart" => crate::ps_script!("Restart-Service {} -ErrorAction Stop", quoted),
"enable" => crate::ps_script!(
"Set-Service {} -StartupType Automatic -ErrorAction Stop",
quoted
),
_ => crate::ps_script!(
"Set-Service {} -StartupType Disabled -ErrorAction Stop",
quoted
),
};
svc_run(
builtin,
"powershell",
&["-NoProfile", "-NonInteractive", "-Command", &script],
)?;
} else if cfg!(target_os = "linux") {
svc_run(builtin, "systemctl", &[action, "--", &name])?;
} else if cfg!(target_os = "macos") {
let domain = format!("system/{name}");
match action {
// launchctl has no restart; kickstart -k kills and restarts the job.
"restart" => svc_run(builtin, "launchctl", &["kickstart", "-k", &domain])?,
"enable" | "disable" => svc_run(builtin, "launchctl", &[action, &domain])?,
_ => svc_run(builtin, "launchctl", &[action, &name])?,
}
} else {
return Err(crate::safety::unimplemented(
builtin,
"service control is not implemented on this OS; nothing was changed",
"use the service manager directly",
));
}
let mut rec = BTreeMap::new();
rec.insert("name".to_string(), Value::Str(name));
rec.insert("action".to_string(), Value::Str(action.to_string()));
Ok(Value::Record(rec))
}
fn bi_svc_start(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
svc_action("svc_start", "start", args, input)
}
fn bi_svc_stop(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
svc_action("svc_stop", "stop", args, input)
}
fn bi_svc_restart(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
svc_action("svc_restart", "restart", args, input)
}
fn bi_svc_enable(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
svc_action("svc_enable", "enable", args, input)
}
fn bi_svc_disable(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
svc_action("svc_disable", "disable", args, input)
}
fn bi_svc_logs(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let name = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"svc_logs",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let lines = args
.get(1)
.and_then(|v| match v {
Value::Int(n) => Some(*n),
_ => None,
})
.unwrap_or(100);
#[cfg(target_os = "windows")]
{
let mut cmd = crate::safety::child_command("powershell");
cmd
.args([
"-Command",
&crate::ps_script!(
// Get-EventLog exits 1 when there are no entries; an empty
// log is [], not a failure.
"ConvertTo-Json -InputObject @(Get-EventLog -LogName Application -Source {} -Newest {} -ErrorAction SilentlyContinue | Select-Object TimeGenerated,EntryType,Message)",
crate::safety::ps_quote(&name), lines
),
]);
let output = bounded_cmd_output("svc_logs", "powershell", cmd, TOOL_DEADLINE_SECS)?;
if output.status.success() {
let json_str = String::from_utf8_lossy(&output.stdout);
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&json_str) {
let items = match &json {
serde_json::Value::Array(arr) => arr.clone(),
obj @ serde_json::Value::Object(_) => vec![obj.clone()],
_ => vec![],
};
let entries: Vec<Value> = items
.iter()
.filter_map(|obj| {
let o = obj.as_object()?;
let mut rec = std::collections::BTreeMap::new();
if let Some(v) = o.get("TimeGenerated") {
rec.insert("timestamp".to_string(), json_to_value(v.clone()));
}
if let Some(v) = o.get("EntryType") {
rec.insert("level".to_string(), json_to_value(v.clone()));
}
if let Some(v) = o.get("Message") {
rec.insert("message".to_string(), json_to_value(v.clone()));
}
Some(Value::Record(rec))
})
.collect();
return Ok(Value::Array(entries));
}
}
// A failed query answered [], "no log entries".
return Err(crate::safety::tool_failed(
"svc_logs",
"Get-EventLog",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
#[cfg(target_os = "linux")]
{
let mut cmd = crate::safety::child_command("journalctl");
cmd.args([
"-u",
&name,
"-n",
&lines.to_string(),
"--no-pager",
"-o",
"json",
]);
let output = bounded_cmd_output("svc_logs", "journalctl", cmd, TOOL_DEADLINE_SECS)?;
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout);
let entries: Vec<Value> = text
.lines()
.filter_map(|line| {
serde_json::from_str::<serde_json::Value>(line)
.ok()
.and_then(|obj| {
let o = obj.as_object()?;
let mut rec = std::collections::BTreeMap::new();
if let Some(v) = o.get("__REALTIME_TIMESTAMP") {
rec.insert("timestamp".to_string(), json_to_value(v.clone()));
}
if let Some(v) = o.get("PRIORITY") {
rec.insert("level".to_string(), json_to_value(v.clone()));
}
if let Some(v) = o.get("MESSAGE") {
rec.insert("message".to_string(), json_to_value(v.clone()));
}
if let Some(v) = o.get("_PID") {
rec.insert("pid".to_string(), json_to_value(v.clone()));
}
Some(Value::Record(rec))
})
})
.collect();
return Ok(Value::Array(entries));
}
// Fallback: text parsing
let mut cmd = crate::safety::child_command("journalctl");
cmd.args(["-u", &name, "-n", &lines.to_string(), "--no-pager"]);
let output = bounded_cmd_output("svc_logs", "journalctl", cmd, TOOL_DEADLINE_SECS)?;
// Unreadable journals (no systemd, no permission) answered [].
if !output.status.success() {
return Err(crate::safety::tool_failed(
"svc_logs",
"journalctl",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
let text = String::from_utf8_lossy(&output.stdout);
let entries: Vec<Value> = text
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| {
let mut rec = std::collections::BTreeMap::new();
rec.insert("message".to_string(), Value::Str(l.to_string()));
Value::Record(rec)
})
.collect();
return Ok(Value::Array(entries));
}
#[cfg(target_os = "macos")]
{
// The name goes into a log predicate inside single quotes; a quote or
// backslash in it would change the predicate.
// Code points, not char literals: see the scanner note in
// platform_disk_usage. 0x27 is the single quote, 0x5c the backslash.
if name.chars().any(|c| matches!(c as u32, 0x27 | 0x5c)) {
return Err(crate::safety::bad_arg(
"svc_logs",
"a process name without quotes or backslashes",
&name,
));
}
let mut cmd = crate::safety::child_command("log");
cmd.args([
"show",
"--predicate",
&format!("process == '{}'", name),
"--last",
"1h",
"--style",
"compact",
]);
let output = bounded_cmd_output("svc_logs", "log", cmd, TOOL_DEADLINE_SECS)?;
if !output.status.success() {
return Err(crate::safety::tool_failed(
"svc_logs",
"log show",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
let text = String::from_utf8_lossy(&output.stdout);
// The newest `lines` entries; this took the oldest. The first line is
// the column header.
let all: Vec<&str> = text
.lines()
.skip(1)
.filter(|l| !l.trim().is_empty())
.collect();
let entries: Vec<Value> = all[all.len().saturating_sub(lines.max(0) as usize)..]
.iter()
.map(|l| {
let mut rec = std::collections::BTreeMap::new();
// macOS log compact format: "timestamp processName[pid] message"
// Try to split at known boundaries
let parts: Vec<&str> = l.splitn(3, " ").collect();
if parts.len() >= 2 {
rec.insert(
"timestamp".to_string(),
Value::Str(parts[0].trim().to_string()),
);
let msg = if parts.len() >= 3 { parts[2] } else { parts[1] };
rec.insert("message".to_string(), Value::Str(msg.trim().to_string()));
} else {
rec.insert("message".to_string(), Value::Str(l.to_string()));
}
Value::Record(rec)
})
.collect();
return Ok(Value::Array(entries));
}
#[allow(unreachable_code)]
{
let _ = (name, lines);
Ok(Value::Null)
}
}
fn bi_svc_info(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
bi_svc_status(args, None)
}
#[path = "services.rs"]
mod services;
fn bi_svc_create(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
services::svc_create(args, input)
}
fn bi_svc_delete(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
services::svc_delete(args, input)
}
fn bi_cron_list(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
if !args.is_empty() || input.is_some() {
return Err(crate::safety::arg_err(
"cron_list accepts no arguments or pipeline input",
));
}
#[cfg(windows)]
{
let script = "$ErrorActionPreference='Stop'; $OutputEncoding=[System.Text.UTF8Encoding]::new($false); [Console]::OutputEncoding=$OutputEncoding; ConvertTo-Json -InputObject @(Get-ScheduledTask -ErrorAction Stop | Select-Object TaskName,@{Name='State';Expression={$_.State.ToString()}},TaskPath) -Depth 3 -Compress";
let mut cmd = crate::safety::child_command("powershell");
cmd.args([
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-Command",
script,
])
.stdin(std::process::Stdio::null());
let output = bounded_cmd_output("cron_list", "powershell", cmd, PROBE_DEADLINE_SECS)?;
parse_cron_listing(output, true)
}
#[cfg(unix)]
{
let mut cmd = crate::safety::child_command("crontab");
cmd.arg("-l")
.env("LC_ALL", "C")
.stdin(std::process::Stdio::null());
let output = bounded_cmd_output("cron_list", "crontab", cmd, PROBE_DEADLINE_SECS)?;
parse_cron_listing(output, false)
}
#[cfg(not(any(unix, windows)))]
{
Err(crate::safety::unimplemented(
"cron_list",
"scheduler queries are unavailable on this platform",
"use a supported platform scheduler",
))
}
}
#[cfg(any(unix, windows, test))]
fn parse_cron_listing(output: std::process::Output, windows: bool) -> Result<Value> {
let tool = if windows {
"powershell Get-ScheduledTask"
} else {
"crontab -l"
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let diagnostic = stderr
.trim()
.strip_prefix("crontab: ")
.unwrap_or(stderr.trim());
let absent = diagnostic
.strip_prefix("no crontab for ")
.is_some_and(|user| !user.is_empty() && !user.contains(['\n', '\r']));
if !windows
&& output.status.code() == Some(1)
&& output.stdout.iter().all(u8::is_ascii_whitespace)
&& absent
{
return Ok(Value::Array(vec![]));
}
return Err(crate::safety::tool_failed(
"cron_list",
tool,
output.status.code(),
&stderr,
));
}
let malformed = || {
crate::safety::bad_state(
"cron_list",
"unexpected scheduler output",
"complete task JSON or supported user-crontab syntax",
)
};
let text = String::from_utf8(output.stdout).map_err(|_| malformed())?;
let mut rows = Vec::new();
if windows {
let json: serde_json::Value = serde_json::from_str(&text).map_err(|_| malformed())?;
let tasks = json.as_array().ok_or_else(malformed)?;
for task in tasks {
let mut row = std::collections::BTreeMap::new();
for (source, target) in [
("TaskName", "name"),
("State", "state"),
("TaskPath", "path"),
] {
let field = task
.get(source)
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.ok_or_else(malformed)?;
row.insert(target.into(), Value::Str(field.into()));
}
rows.push(Value::Record(row));
}
} else {
for line in text.lines() {
let mut rest = line.trim_start();
if rest.is_empty() || rest.starts_with('#') {
continue;
}
if let Some((name, _)) = rest.split_once('=') {
let name = name.trim();
let mut chars = name.bytes();
if chars
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == b'_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == b'_')
{
continue;
}
}
let mut fields = Vec::new();
let count = if rest.starts_with('@') { 1 } else { 5 };
for _ in 0..count {
let end = rest.find(char::is_whitespace).ok_or_else(malformed)?;
fields.push(&rest[..end]);
rest = rest[end..].trim_start();
}
if rest.is_empty() {
return Err(malformed());
}
if count == 1
&& !matches!(
fields[0],
"@reboot"
| "@yearly"
| "@annually"
| "@monthly"
| "@weekly"
| "@daily"
| "@midnight"
| "@hourly"
)
{
return Err(malformed());
}
let mut row = std::collections::BTreeMap::new();
row.insert("schedule".into(), Value::Str(fields.join(" ")));
row.insert("command".into(), Value::Str(rest.into()));
rows.push(Value::Record(row));
}
}
Ok(Value::Array(rows))
}
#[cfg(all(test, any(unix, windows)))]
mod cron_listing_tests {
use super::*;
fn output(code: u32, stdout: &[u8], stderr: &[u8]) -> std::process::Output {
#[cfg(unix)]
let status = {
use std::os::unix::process::ExitStatusExt;
std::process::ExitStatus::from_raw((code as i32) << 8)
};
#[cfg(windows)]
let status = {
use std::os::windows::process::ExitStatusExt;
std::process::ExitStatus::from_raw(code)
};
std::process::Output {
status,
stdout: stdout.to_vec(),
stderr: stderr.to_vec(),
}
}
#[test]
fn windows_tasks_require_complete_string_records_and_a_real_empty_array() {
assert!(
matches!(parse_cron_listing(output(0,b"[]",b""),true).unwrap(),Value::Array(a) if a.is_empty())
);
let rows = parse_cron_listing(
output(
0,
br#"[{"TaskName":"test","State":"Ready","TaskPath":"/"}]"#,
b"",
),
true,
)
.unwrap();
assert!(matches!(rows,Value::Array(a) if a.len()==1));
for text in [
"",
"null",
"1",
"{}",
"[{}]",
"[null]",
r#"[{"TaskName":"test","State":3,"TaskPath":"/"}]"#,
r#"[{"TaskName":"test","State":"Ready"}]"#,
] {
let err = parse_cron_listing(output(0, text.as_bytes(), b""), true)
.unwrap_err()
.to_string();
assert!(err.contains("E_BAD_STATE"), "{err}");
}
}
#[test]
fn unix_lines_preserve_commands_skip_environment_and_accept_special_schedules() {
let Value::Array(rows)=parse_cron_listing(output(0,b"# comment\nSHELL = /bin/sh\nMAILTO=\"a@b\"\n 0 1\t* * * echo a b=1\n@reboot echo ready\n",b""),false).unwrap() else {panic!("array")};
assert_eq!(rows.len(), 2);
let Value::Record(first) = &rows[0] else {
panic!("record")
};
assert_eq!(first.get("schedule"), Some(&Value::Str("0 1 * * *".into())));
assert_eq!(
first.get("command"),
Some(&Value::Str("echo a b=1".into()))
);
for text in [
"0 1 * *",
"0 1 * * * ",
"@unknown command",
"0 1 * * * echo ok\ninvalid",
] {
assert!(parse_cron_listing(output(0, text.as_bytes(), b""), false)
.unwrap_err()
.to_string()
.contains("E_BAD_STATE"));
}
}
#[test]
fn only_a_specific_absent_crontab_diagnostic_means_no_jobs() {
assert!(
matches!(parse_cron_listing(output(1,b"",b"no crontab for test\n"),false).unwrap(),Value::Array(a) if a.is_empty())
);
for (code, stdout, stderr, windows) in [
(2, b"".as_slice(), b"no crontab for test".as_slice(), false),
(1, b"unexpected", b"no crontab for test", false),
(1, b"", b"permission denied: no crontab for test", false),
(1, b"", b"no crontab for test", true),
] {
assert!(parse_cron_listing(output(code, stdout, stderr), windows)
.unwrap_err()
.to_string()
.contains("E_TOOL_FAILED"));
}
for windows in [false, true] {
assert!(parse_cron_listing(output(0, b"\xff", b""), windows)
.unwrap_err()
.to_string()
.contains("E_BAD_STATE"));
}
}
}
#[path = "scheduler.rs"]
mod scheduler;
fn bi_cron_add(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
scheduler::guard_and_add(args, input)
}
fn bi_cron_remove(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
guard_and_change_cron("cron_remove", args, input, "remove")
}
fn bi_cron_enable(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
guard_and_change_cron("cron_enable", args, input, "enable")
}
fn bi_cron_disable(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
guard_and_change_cron("cron_disable", args, input, "disable")
}
fn guard_and_change_cron(
name: &str,
args: Vec<Value>,
input: Option<Value>,
action: &str,
) -> Result<Value> {
let [Value::Str(target)] = args.as_slice() else {
return Err(crate::safety::arg_err(format!(
"{name} requires one String target"
)));
};
if input.is_some()
|| target.trim().is_empty()
|| target.contains(['\0', '\n', '\r'])
|| target.starts_with(['-', '/'])
|| target.len() > 65536
{
return Err(crate::safety::arg_err(
"invalid scheduler target or pipeline input",
));
}
if cfg!(windows) && target.contains(['*', '?']) {
return Err(crate::safety::arg_err(
"scheduler target must name one task; wildcards are forbidden",
));
}
if cfg!(unix) {
let parsed = parse_cron_listing(cron_success_output(target.as_bytes().to_vec()), false)
.map_err(|_| crate::safety::arg_err("target must be one complete crontab job"))?;
if !matches!(parsed, Value::Array(ref rows) if rows.len() == 1) {
return Err(crate::safety::arg_err(
"target must be one complete crontab job",
));
}
}
if crate::tx::is_active() {
return Err(crate::safety::unimplemented(
name,
"scheduler changes cannot be rolled back",
"finish the transaction first",
));
}
if action == "enable" {
crate::safety::guard_exec(name, format!("enable scheduled job: {target}"))?;
} else {
crate::safety::guard(crate::safety::GuardCtx {
builtin: name,
effect: crate::safety::effect_of(name),
what: action,
targets: vec![target.clone()],
blast_radius: serde_json::json!({"scheduled_jobs": 1}),
reversible: false,
fs_paths: false,
})?;
}
change_cron_backend(
name,
target,
action,
cfg!(windows),
|tool, arguments, stdin| scheduler::run_tool(name, tool, arguments, stdin),
)
}
// This lock serializes this process's read/modify/write operations. Other crontab
// clients can still race; the native user-crontab API has no compare-and-swap.
static CRON_CHANGE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn change_cron_backend(
name: &str,
target: &str,
action: &str,
windows: bool,
mut run: impl FnMut(&str, &[&str], Option<&[u8]>) -> Result<std::process::Output>,
) -> Result<Value> {
if windows && target.contains(['*', '?']) {
return Err(crate::safety::arg_err(
"scheduler target must name one task; wildcards are forbidden",
));
}
let _lock = CRON_CHANGE_LOCK.lock().map_err(|_| {
crate::safety::bad_state(name, "scheduler lock poisoned", "restart the runtime")
})?;
let check = |output: std::process::Output, tool: &str| -> Result<Vec<u8>> {
if !output.status.success() {
return Err(crate::safety::tool_failed(
name,
tool,
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
Ok(output.stdout)
};
if windows {
let arguments = match action {
"remove" => vec!["/Delete", "/TN", target, "/F"],
"enable" => vec!["/Change", "/TN", target, "/ENABLE"],
"disable" => vec!["/Change", "/TN", target, "/DISABLE"],
_ => return Err(crate::safety::arg_err("unknown scheduler action")),
};
check(run("schtasks", &arguments, None)?, "schtasks")?;
} else {
let bytes = check(run("crontab", &["-l"], None)?, "crontab")?;
let text = std::str::from_utf8(&bytes).map_err(|_| {
crate::safety::bad_state(name, "non-UTF-8 crontab", "UTF-8 user crontab")
})?;
let replacement = transform_cron_entry(text, target, action)?;
if replacement == text {
return Ok(Value::Bool(true));
}
check(
run("crontab", &["-"], Some(replacement.as_bytes()))?,
"crontab",
)?;
}
Ok(Value::Bool(true))
}
fn transform_cron_entry(text: &str, target: &str, action: &str) -> Result<String> {
const DISABLED: &str = "# ironshell-disabled: ";
if target.starts_with('#') || target.trim().is_empty() {
return Err(crate::safety::arg_err(
"target must be an active crontab entry, without a comment prefix",
));
}
// Validate the selected entry using the same parser as listing. It must be
// exactly one job, never an environment assignment or a comment.
let parsed = parse_cron_listing(cron_success_output(target.as_bytes().to_vec()), false)
.map_err(|_| crate::safety::arg_err("target must be one complete crontab job"))?;
if !matches!(parsed, Value::Array(ref rows) if rows.len() == 1) {
return Err(crate::safety::arg_err(
"target must be one complete crontab job",
));
}
let disabled = format!("{DISABLED}{target}");
let matches = text
.lines()
.filter(|line| *line == target || *line == disabled)
.count();
if matches != 1 {
return Err(crate::safety::bad_state(
"cron",
&format!("expected one matching job, found {matches}"),
"select a unique existing crontab entry",
));
}
let mut result = String::new();
for line in text.split_inclusive('\n') {
let entry = line.strip_suffix('\n').unwrap_or(line);
if entry == target || entry == disabled {
match action {
"remove" => continue,
"enable" => result.push_str(target),
"disable" => result.push_str(&disabled),
_ => return Err(crate::safety::arg_err("unknown scheduler action")),
}
if line.ends_with('\n') {
result.push('\n');
}
} else {
result.push_str(line);
}
}
Ok(result)
}
fn cron_success_output(stdout: Vec<u8>) -> std::process::Output {
#[cfg(unix)]
let status = {
use std::os::unix::process::ExitStatusExt;
std::process::ExitStatus::from_raw(0)
};
#[cfg(windows)]
let status = {
use std::os::windows::process::ExitStatusExt;
std::process::ExitStatus::from_raw(0)
};
std::process::Output {
status,
stdout,
stderr: vec![],
}
}
#[cfg(test)]
mod cron_mutation_tests {
use super::*;
#[test]
fn transformations_preserve_unrelated_bytes_and_are_idempotent() {
let job = "0 1 * * * echo hello";
let text = format!("# owned comment\nPATH=/bin\n{job}\n@daily other");
let disabled = transform_cron_entry(&text, job, "disable").unwrap();
assert_eq!(
disabled,
format!("# owned comment\nPATH=/bin\n# ironshell-disabled: {job}\n@daily other")
);
assert_eq!(
transform_cron_entry(&disabled, job, "disable").unwrap(),
disabled
);
assert_eq!(
transform_cron_entry(&disabled, job, "enable").unwrap(),
text
);
assert_eq!(
transform_cron_entry(&text, job, "remove").unwrap(),
"# owned comment\nPATH=/bin\n@daily other"
);
for invalid in ["PATH=/bin", "# comment", "@unknown x"] {
assert!(transform_cron_entry(&text, invalid, "remove").is_err());
}
assert!(transform_cron_entry(&format!("{job}\n{job}\n"), job, "remove").is_err());
assert!(transform_cron_entry("", job, "remove").is_err());
}
#[test]
fn failures_never_report_success_or_write_after_failed_read() {
for windows in [true, false] {
let mut calls = 0;
let result = change_cron_backend(
"cron_remove",
"@daily echo test",
"remove",
windows,
|_, _, _| {
calls += 1;
let mut output = cron_success_output(vec![]);
#[cfg(windows)]
{
use std::os::windows::process::ExitStatusExt;
output.status = std::process::ExitStatus::from_raw(1);
}
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
output.status = std::process::ExitStatus::from_raw(256);
}
output.stderr = b"access denied".to_vec();
Ok(output)
},
);
assert!(result.unwrap_err().to_string().contains("E_TOOL_FAILED"));
assert_eq!(calls, 1);
}
for target in ["*", "\\Folder\\*"] {
let result = change_cron_backend("cron_remove", target, "remove", true, |_, _, _| {
panic!("wildcard reached native backend")
});
assert!(result.unwrap_err().to_string().contains("E_BAD_ARG"));
}
}
#[test]
fn native_backends_receive_exact_targets_and_checked_updates() {
let job = "@daily echo hello";
let mut calls = 0;
assert_eq!(
change_cron_backend(
"cron_disable",
job,
"disable",
false,
|tool, args, stdin| {
assert_eq!(tool, "crontab");
calls += 1;
if calls == 1 {
assert_eq!(args, &["-l"]);
assert!(stdin.is_none());
Ok(cron_success_output(format!("{job}\n").into_bytes()))
} else {
assert_eq!(args, &["-"]);
assert_eq!(
stdin.unwrap(),
format!("# ironshell-disabled: {job}\n").as_bytes()
);
Ok(cron_success_output(vec![]))
}
}
)
.unwrap(),
Value::Bool(true)
);
assert_eq!(calls, 2);
for (action, expected) in [
("remove", vec!["/Delete", "/TN", "\\Owned task", "/F"]),
("enable", vec!["/Change", "/TN", "\\Owned task", "/ENABLE"]),
(
"disable",
vec!["/Change", "/TN", "\\Owned task", "/DISABLE"],
),
] {
change_cron_backend("cron", "\\Owned task", action, true, |tool, args, stdin| {
assert_eq!(tool, "schtasks");
assert_eq!(args, expected);
assert!(stdin.is_none());
Ok(cron_success_output(vec![]))
})
.unwrap();
}
}
}
fn bi_at_schedule(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
scheduler::guard_and_schedule(args, input)
}
fn bi_at_list(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
if !args.is_empty() || input.is_some() {
return Err(crate::safety::arg_err(
"at_list accepts no arguments or pipeline input",
));
}
#[cfg(unix)]
{
let mut cmd = crate::safety::child_command("atq");
cmd.stdin(std::process::Stdio::null());
let output = bounded_cmd_output("at_list", "atq", cmd, PROBE_DEADLINE_SECS)?;
parse_at_queue(output)
}
#[cfg(windows)]
{
scheduler::list_windows_at()
}
#[cfg(not(any(unix, windows)))]
{
Err(crate::safety::unimplemented(
"at_list",
"unsupported scheduler platform",
"use a supported platform",
))
}
}
#[cfg(any(unix, test))]
fn parse_at_queue(output: std::process::Output) -> Result<Value> {
if !output.status.success() {
return Err(crate::safety::tool_failed(
"at_list",
"atq",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
let malformed = || {
crate::safety::bad_state(
"at_list",
"unexpected atq output",
"tab-separated job ID and schedule/queue/user layout",
)
};
let text = String::from_utf8(output.stdout).map_err(|_| malformed())?;
let mut jobs = Vec::new();
for line in text.lines().filter(|l| !l.trim().is_empty()) {
let (id, rest) = line.split_once('\t').ok_or_else(malformed)?;
let id = id.trim();
if id.is_empty()
|| !id.bytes().all(|b| b.is_ascii_digit())
|| id.parse::<u64>().ok().is_none_or(|n| n == 0)
{
return Err(malformed());
}
let tokens: Vec<_> = rest.split_whitespace().collect();
if tokens.len() < 3 {
return Err(malformed());
}
let mut rec = std::collections::BTreeMap::new();
rec.insert("job_id".into(), Value::Str(id.into()));
rec.insert("queue".into(), Value::Str(tokens[tokens.len() - 2].into()));
rec.insert("user".into(), Value::Str(tokens[tokens.len() - 1].into()));
// Keep the previous schedule field: the entire unparsed tail, including
// queue and user. Date/locale normalization is not claimed.
rec.insert("schedule".into(), Value::Str(rest.trim().into()));
jobs.push(Value::Record(rec));
}
Ok(Value::Array(jobs))
}
#[cfg(all(test, any(unix, windows)))]
mod at_queue_contract_tests {
use super::*;
fn output(success: bool, bytes: &[u8]) -> std::process::Output {
#[cfg(unix)]
let status = {
use std::os::unix::process::ExitStatusExt;
std::process::ExitStatus::from_raw(if success { 0 } else { 256 })
};
#[cfg(windows)]
let status = {
use std::os::windows::process::ExitStatusExt;
std::process::ExitStatus::from_raw(if success { 0 } else { 1 })
};
std::process::Output {
status,
stdout: bytes.to_vec(),
stderr: b"permission denied".to_vec(),
}
}
#[test]
fn successful_empty_and_populated_queues_have_distinct_results() {
assert!(
matches!(parse_at_queue(output(true,b" \n")).unwrap(),Value::Array(a) if a.is_empty())
);
let Value::Array(rows) =
parse_at_queue(output(true, b"12\tFri Oct 2 10:00:00 2026 a user \n")).unwrap()
else {
panic!("expected array")
};
let Value::Record(row) = &rows[0] else {
panic!("expected record")
};
assert_eq!(row.get("job_id"), Some(&Value::Str("12".into())));
assert_eq!(row.get("queue"), Some(&Value::Str("a".into())));
assert_eq!(row.get("user"), Some(&Value::Str("user".into())));
assert_eq!(
row.get("schedule"),
Some(&Value::Str("Fri Oct 2 10:00:00 2026 a user".into()))
);
}
#[test]
fn unsuccessful_queries_and_malformed_successes_are_coded() {
for bytes in [b"".as_slice(), b"12\tdate a user", b"\xff"] {
let err = parse_at_queue(output(false, bytes))
.unwrap_err()
.to_string();
assert!(
err.contains("E_TOOL_FAILED") && err.contains("permission denied"),
"{err}"
);
}
for bytes in [
b"\xff".as_slice(),
b"noise",
b"0\tdate a user",
b"x\tdate a user",
b"1\ta user",
b"1\tdate a user\nnoise",
] {
let err = parse_at_queue(output(true, bytes)).unwrap_err().to_string();
assert!(err.contains("E_BAD_STATE"), "{err}");
}
}
}
fn bi_at_remove(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
scheduler::guard_and_remove(args, input)
}
fn bi_startup_list(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
#[cfg(target_os = "windows")]
{
let mut cmd = crate::safety::child_command("powershell");
cmd
.args(["-Command", "Get-CimInstance Win32_StartupCommand | Select-Object Name,Command,Location | ConvertTo-Json"]);
let output = bounded_cmd_output("startup_list", "powershell", cmd, PROBE_DEADLINE_SECS)?;
if output.status.success() {
let json_str = String::from_utf8_lossy(&output.stdout);
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&json_str) {
let items = match &json {
serde_json::Value::Array(arr) => arr.clone(),
obj @ serde_json::Value::Object(_) => vec![obj.clone()],
_ => vec![],
};
let startup: Vec<Value> = items
.iter()
.filter_map(|obj| {
let o = obj.as_object()?;
let mut rec = std::collections::BTreeMap::new();
if let Some(v) = o.get("Name") {
rec.insert("name".to_string(), json_to_value(v.clone()));
}
if let Some(v) = o.get("Command") {
rec.insert("command".to_string(), json_to_value(v.clone()));
}
if let Some(v) = o.get("Location") {
rec.insert("location".to_string(), json_to_value(v.clone()));
}
Some(Value::Record(rec))
})
.collect();
return Ok(Value::Array(startup));
}
// With no startup commands PowerShell prints nothing at all.
if String::from_utf8_lossy(&output.stdout).trim().is_empty() {
return Ok(Value::Array(vec![]));
}
}
return Err(crate::safety::tool_failed(
"startup_list",
"powershell Get-CimInstance Win32_StartupCommand",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
#[cfg(target_os = "linux")]
{
let mut cmd = crate::safety::child_command("systemctl");
cmd.args([
"list-unit-files",
"--type=service",
"--state=enabled",
"--no-pager",
"--no-legend",
]);
let output = bounded_cmd_output("startup_list", "systemctl", cmd, PROBE_DEADLINE_SECS)?;
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout);
let services: Vec<Value> = text
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| {
let parts: Vec<&str> = l.split_whitespace().collect();
let mut rec = std::collections::BTreeMap::new();
rec.insert(
"name".to_string(),
Value::Str(parts.first().unwrap_or(&"").to_string()),
);
rec.insert(
"state".to_string(),
Value::Str(parts.get(1).unwrap_or(&"").to_string()),
);
Value::Record(rec)
})
.collect();
return Ok(Value::Array(services));
}
// Without systemd (a container, WSL without it) systemctl fails; that
// is not "nothing starts at boot".
return Err(crate::safety::tool_failed(
"startup_list",
"systemctl list-unit-files",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
#[cfg(target_os = "macos")]
{
let mut cmd = crate::safety::child_command("launchctl");
cmd.args(["list"]);
let output = bounded_cmd_output("startup_list", "launchctl", cmd, PROBE_DEADLINE_SECS)?;
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout);
let services: Vec<Value> = text
.lines()
.skip(1) // skip header
.filter(|l| !l.trim().is_empty())
.map(|l| {
let parts: Vec<&str> = l.split('\t').collect();
let mut rec = std::collections::BTreeMap::new();
rec.insert(
"pid".to_string(),
Value::Str(parts.first().unwrap_or(&"").trim().to_string()),
);
rec.insert(
"exit_code".to_string(),
Value::Str(parts.get(1).unwrap_or(&"").trim().to_string()),
);
rec.insert(
"name".to_string(),
Value::Str(parts.get(2).unwrap_or(&"").trim().to_string()),
);
Value::Record(rec)
})
.collect();
return Ok(Value::Array(services));
}
return Err(crate::safety::tool_failed(
"startup_list",
"launchctl list",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
// Every failure above, and every other operating system, used to land
// here as `[]`: "nothing starts at boot", said without looking.
#[allow(unreachable_code)]
Err(crate::safety::unimplemented(
"startup_list",
"listing startup items is implemented for Windows, Linux (systemd) and macOS only; NOTHING WAS INSPECTED",
"use this system's init tooling directly",
))
}
// ============================================================================
// 330-349: Archive & Compression
// ============================================================================
/// The file operands of a zip call: every argument after the archive, each of
/// which must be a string. At least one is required.
fn zip_file_args(builtin: &str, args: &[Value]) -> Result<Vec<String>> {
let files = args
.iter()
.skip(1)
.map(|v| match v {
Value::Str(s) => Ok(s.clone()),
other => Err(crate::safety::bad_arg(
builtin,
"a file name: String",
other.type_name(),
)),
})
.collect::<Result<Vec<String>>>()?;
if files.is_empty() {
return Err(crate::safety::bad_arg(
builtin,
"at least one file to add",
"none",
));
}
Ok(files)
}
/// The command that writes a zip archive, or with `update` refreshes one. Both
/// the Unix zip and PowerShell's Compress-Archive are run from an argv.
fn zip_command(
archive: &str,
files: &[String],
update: bool,
) -> (&'static str, std::process::Command) {
if cfg!(target_os = "windows") {
let mut cmd = crate::safety::child_command("powershell");
if update {
let script = crate::ps_script!(
"Compress-Archive -Path {} -Update -DestinationPath {}",
crate::safety::ps_join(files.iter().map(|f| crate::safety::ps_quote(f)), ","),
crate::safety::ps_quote(archive)
);
cmd.args(["-NoProfile", "-Command", &script]);
} else {
let script = crate::ps_script!(
"Compress-Archive -Path {} -DestinationPath {}",
crate::safety::ps_join(files.iter().map(|f| crate::safety::ps_quote(f)), ","),
crate::safety::ps_quote(archive)
);
cmd.args(["-NoProfile", "-Command", &script]);
}
("powershell", cmd)
} else {
let mut cmd = crate::safety::child_command("zip");
cmd.arg("-q");
if update {
cmd.arg("-u");
}
cmd.arg(archive).args(files);
("zip", cmd)
}
}
// 330: bi_zip_create - Write a new zip archive from files.
//
// An existing archive is refused rather than added to: that is zip_add's job.
// Every file operand must be a string, and at least one is required. A failed
// run removes the archive it started.
fn bi_zip_create(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("zip_create", &input)?;
let archive = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"zip_create",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let files = zip_file_args("zip_create", &args)?;
crate::safety::reject_option_like("zip_create", std::slice::from_ref(&archive))?;
crate::safety::reject_option_like("zip_create", &files)?;
let archive = guard_local_write("zip_create", &crate::safety::resolve_path_str(&archive))?;
refuse_existing("zip_create", &archive)?;
let files: Vec<String> = files
.iter()
.map(|f| crate::safety::resolve_path_str(f))
.collect();
// The archive is new (refused above if it exists), so rollback removes it.
crate::tx::snapshot(&archive);
let (tool, cmd) = zip_command(&archive, &files, false);
if let Err(e) = run_checked("zip_create", tool, cmd, LOCAL_DEADLINE_SECS) {
remove_partial(&archive);
return Err(e);
}
Ok(Value::Bool(true))
}
// Archive entries are checked before the extracting tool runs. The tool would
// write each member relative to the destination, so a member whose name is
// absolute, or climbs out with `..`, lands outside it ("zip slip"). A symlink
// or hard link whose target does the same is refused too, since a later member
// could then be written through it. Nothing is written if any entry is refused.
/// True when an archive entry name, or a link target, can resolve outside the
/// directory the archive is extracted into: an absolute path, a drive path
/// (`C:x`, `C:\x`), a UNC path, or any `..` component. Both `/` and `\` separate
/// components, because one archive may be extracted on either platform.
fn archive_entry_escapes(name: &str) -> bool {
if name.starts_with('/') || name.starts_with('\\') {
return true;
}
let b = name.as_bytes();
if b.len() >= 2 && b[1] == b':' && b[0].is_ascii_alphabetic() {
return true;
}
name.split(['/', '\\']).any(|part| part == "..")
}
/// Refuse the first entry, or link target, that would land outside the
/// destination, and any archive with more than `WALK_MAX_ENTRIES` entries.
/// Each item is an entry name and, for a symlink or hard link, its target.
fn check_archive_entries(
builtin: &str,
entries: impl Iterator<Item = (String, Option<String>)>,
) -> Result<()> {
for (n, (name, target)) in entries.enumerate() {
if n >= WALK_MAX_ENTRIES {
return Err(walk_over(builtin, WALK_MAX_ENTRIES));
}
if archive_entry_escapes(&name) {
return Err(refuse_archive_entry(builtin, &name));
}
if let Some(t) = target {
if archive_entry_escapes(&t) {
return Err(refuse_archive_entry(builtin, &name));
}
}
}
Ok(())
}
/// The most an archive may declare as uncompressed content before
/// `zip_extract` or `tar_extract` refuses it: the sum of its members' sizes.
const ARCHIVE_EXTRACT_MAX_BYTES: u64 = 4 * 1024 * 1024 * 1024;
/// Refuse an archive whose members declare more than `ARCHIVE_EXTRACT_MAX_BYTES`
/// in total, before anything is written. Declared sizes can lie: a zip's
/// uncompressed size and a tar header's size are written by whoever made the
/// archive, so a small archive can claim little and inflate to far more. This
/// bounds what the archive claims, not what the extracting tool writes. The
/// tool's deadline (`TOOL_LONG_DEADLINE_SECS`) is what bounds the work done.
fn check_archive_budget(builtin: &str, declared: u64) -> Result<()> {
if declared > ARCHIVE_EXTRACT_MAX_BYTES {
return Err(crate::safety::budget_exceeded(
builtin,
format!(
"the archive declares {declared} bytes uncompressed, over the {ARCHIVE_EXTRACT_MAX_BYTES}-byte limit"
),
"nothing was extracted; extract the parts you need, or split the archive",
));
}
Ok(())
}
/// Refuse an archive whose members, or whose links' targets, would land on a
/// path `guard` protects: the audit log, the governance files, or in agent
/// mode a `.git` directory. The extracting tool writes them where the
/// archive says, so `guard` never sees those paths; they are judged here,
/// against the resolved destination, before anything is written.
fn check_archive_protected(
builtin: &str,
dest: &str,
entries: &[(String, Option<String>)],
) -> Result<()> {
let root = std::path::Path::new(dest);
for (name, target) in entries {
let landing = root.join(name);
let mut paths = vec![landing.clone()];
if let Some(t) = target {
paths.push(landing.parent().unwrap_or(root).join(t));
}
for p in paths {
let p = p.to_string_lossy();
if let Some(why) = crate::safety::protected_write_refusal(&p) {
return Err(crate::safety::policy_deny(
builtin,
format!("{builtin}: entry `{name}` would write {p}, which is {why}"),
"the archive was refused before anything was written; extract it somewhere else or remove the entry",
));
}
}
}
Ok(())
}
fn refuse_archive_entry(builtin: &str, name: &str) -> anyhow::Error {
crate::safety::policy_deny(
builtin,
format!("{builtin}: entry `{name}` would land outside the destination"),
"the archive was refused before anything was written; remove the entry \
or rebuild the archive with relative paths that stay inside it",
)
}
/// Largest symlink target read from a zip entry. Longer targets are not read in
/// full; they are refused as if absolute.
const ARCHIVE_LINK_TARGET_MAX: u64 = 4096;
/// List every entry of a zip archive, with the target of each symlink entry.
/// The zip format stores a symlink as a member whose unix mode is `S_IFLNK` and
/// whose content is the target path.
fn zip_archive_entries(
builtin: &str,
archive: &str,
) -> Result<(Vec<(String, Option<String>)>, u64)> {
use std::io::Read as _;
let file =
std::fs::File::open(archive).map_err(|e| crate::safety::fs_error(builtin, archive, &e))?;
let mut zip = zip::ZipArchive::new(file).map_err(|e| {
crate::safety::tool_failed(builtin, "zip", None, &format!("not a zip archive: {e}"))
})?;
if zip.len() > WALK_MAX_ENTRIES {
return Err(walk_over(builtin, WALK_MAX_ENTRIES));
}
let mut out = Vec::with_capacity(zip.len());
let mut declared: u64 = 0;
for i in 0..zip.len() {
let mut entry = zip.by_index(i).map_err(|e| {
crate::safety::tool_failed(builtin, "zip", None, &format!("unreadable entry: {e}"))
})?;
declared = declared.saturating_add(entry.size());
let name = entry
.name()
.map(|n| n.into_owned())
.unwrap_or_else(|_| String::from_utf8_lossy(entry.name_raw()).into_owned());
let is_link = matches!(entry.unix_mode(), Some(m) if m & 0o170000 == 0o120000);
let target = if is_link {
let mut buf = Vec::new();
std::io::Read::take(&mut entry, ARCHIVE_LINK_TARGET_MAX + 1)
.read_to_end(&mut buf)
.map_err(|e| {
crate::safety::tool_failed(
builtin,
"zip",
None,
&format!("unreadable link: {e}"),
)
})?;
if buf.len() as u64 > ARCHIVE_LINK_TARGET_MAX {
Some("/".to_string())
} else {
Some(String::from_utf8_lossy(&buf).into_owned())
}
} else {
None
};
out.push((name, target));
}
Ok((out, declared))
}
/// The member names of a compressed tar archive, as `tar -tf` lists them. The
/// sizes are not listed, so the declared total is 0: `tar -tf` gives no size.
fn tar_tool_entry_names(
builtin: &str,
archive: &str,
) -> Result<(Vec<(String, Option<String>)>, u64)> {
crate::safety::reject_option_like(builtin, &[archive.to_string()])?;
let mut cmd = crate::safety::child_command("tar");
cmd.arg("-tf").arg(archive);
let out = bounded_cmd_output(builtin, "tar", cmd, TOOL_DEADLINE_SECS)?;
if !out.status.success() {
return Err(crate::safety::tool_failed(
builtin,
"tar -tf",
out.status.code(),
&String::from_utf8_lossy(&out.stderr),
));
}
let text = String::from_utf8_lossy(&out.stdout);
let mut names = Vec::new();
for line in text.lines().filter(|l| !l.is_empty()) {
if names.len() >= WALK_MAX_ENTRIES {
return Err(walk_over(builtin, WALK_MAX_ENTRIES));
}
names.push((line.to_string(), None));
}
Ok((names, 0))
}
/// The program that decompresses a bzip2, xz or zstd stream, by its first two
/// bytes.
fn tar_stream_decompressor(magic: [u8; 2]) -> Option<&'static str> {
match magic {
[0x42, 0x5a] => Some("bzip2"), // "BZ"
[0xfd, 0x37] => Some("xz"),
[0x28, 0xb5] => Some("zstd"),
_ => None,
}
}
/// List a bzip2, xz or zstd tar by streaming it through its decompressor into
/// the in-process reader, so symlink and hard-link targets are checked as they
/// are for gzip. `None` when the decompressor is not installed: bsdtar has
/// these formats built in and needs no separate program. Past the tool deadline
/// the decompressor is killed and the call is a budget error.
fn tar_decompressed_entries(
builtin: &str,
archive: &str,
tool: &str,
) -> Result<Option<(Vec<(String, Option<String>)>, u64)>> {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
crate::safety::reject_option_like(builtin, &[archive.to_string()])?;
let mut decoder = crate::safety::child_command(tool);
crate::safety::strip_ironshell_env(&mut decoder);
let mut child = match decoder
.arg("-dc")
.arg("--")
.arg(archive)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
{
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(crate::safety::spawn_error(builtin, tool, &e)),
};
let mut stdout = child.stdout.take().expect("stdout is piped");
let child = Arc::new(Mutex::new(child));
let done = Arc::new(AtomicBool::new(false));
let timed_out = Arc::new(AtomicBool::new(false));
let watcher = {
let (child, done, timed_out) = (child.clone(), done.clone(), timed_out.clone());
std::thread::spawn(move || {
let start = std::time::Instant::now();
while !done.load(Ordering::SeqCst) {
if start.elapsed() >= std::time::Duration::from_secs(TOOL_DEADLINE_SECS) {
timed_out.store(true, Ordering::SeqCst);
let _ = child.lock().unwrap_or_else(|e| e.into_inner()).kill();
return;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
})
};
// Read to the end, past the tar's end-of-archive blocks, so the exit status
// says whether the whole stream decompressed.
let listed = read_tar_entries(builtin, &mut stdout).and_then(|v| {
std::io::copy(&mut stdout, &mut std::io::sink())
.map(|_| v)
.map_err(|e| crate::safety::tool_failed(builtin, tool, None, &e.to_string()))
});
done.store(true, Ordering::SeqCst);
let _ = watcher.join();
let status = {
let mut c = child.lock().unwrap_or_else(|e| e.into_inner());
if listed.is_err() {
let _ = c.kill();
}
c.wait()
.map_err(|e| crate::safety::spawn_error(builtin, tool, &e))?
};
if timed_out.load(Ordering::SeqCst) {
return Err(crate::safety::budget_exceeded(
builtin,
format!("`{tool}` did not finish within {TOOL_DEADLINE_SECS} s"),
"nothing was extracted; the archive was not checked in time",
));
}
let listed = listed?;
if !status.success() {
return Err(crate::safety::tool_failed(
builtin,
tool,
status.code(),
"the stream did not decompress cleanly",
));
}
Ok(Some(listed))
}
/// List every entry of a tar archive, with the target of each symlink and hard
/// link. See `tar_archive_listing` for the declared sizes.
fn tar_archive_entries(builtin: &str, archive: &str) -> Result<Vec<(String, Option<String>)>> {
Ok(tar_archive_listing(builtin, archive)?.0)
}
/// The entries of a tar archive, and the total of their declared sizes. A plain
/// or gzip-compressed tar is read in-process. A bzip2, xz or zstd stream is read
/// in-process through its decompressor; where that program is not installed,
/// only its member names are checked, as `tar -tf` lists them, and no size is
/// known, so the declared total for that case is 0.
fn tar_archive_listing(
builtin: &str,
archive: &str,
) -> Result<(Vec<(String, Option<String>)>, u64)> {
use std::io::{Read, Seek, SeekFrom};
let mut file =
std::fs::File::open(archive).map_err(|e| crate::safety::fs_error(builtin, archive, &e))?;
let mut magic = [0u8; 2];
let n = file
.read(&mut magic)
.map_err(|e| crate::safety::fs_error(builtin, archive, &e))?;
file.seek(SeekFrom::Start(0))
.map_err(|e| crate::safety::fs_error(builtin, archive, &e))?;
let reader: Box<dyn Read> = if n == 2 && magic == [0x1f, 0x8b] {
Box::new(flate2::read::GzDecoder::new(file))
} else if let Some(tool) = tar_stream_decompressor(magic).filter(|_| n == 2) {
drop(file);
if let Some(listed) = tar_decompressed_entries(builtin, archive, tool)? {
return Ok(listed);
}
// No decompressor on PATH, so the names come from the tar that will
// extract them. Link targets are not listed this way; the tool's own
// protection (GNU tar and bsdtar strip leading `/`, refuse `..`
// members, and delay symlinks) covers those.
return tar_tool_entry_names(builtin, archive);
} else {
Box::new(file)
};
read_tar_entries(builtin, reader)
}
/// Every entry of an uncompressed tar stream, with the target of each symlink
/// and hard link.
fn read_tar_entries<R: std::io::Read>(
builtin: &str,
reader: R,
) -> Result<(Vec<(String, Option<String>)>, u64)> {
let mut ar = tar::Archive::new(reader);
let entries = ar.entries().map_err(|e| {
crate::safety::tool_failed(builtin, "tar", None, &format!("not a tar archive: {e}"))
})?;
let mut out = Vec::new();
let mut declared: u64 = 0;
for entry in entries {
if out.len() >= WALK_MAX_ENTRIES {
return Err(walk_over(builtin, WALK_MAX_ENTRIES));
}
let entry = entry.map_err(|e| {
crate::safety::tool_failed(builtin, "tar", None, &format!("unreadable entry: {e}"))
})?;
declared = declared.saturating_add(entry.size());
let name = entry
.path()
.map_err(|e| crate::safety::tool_failed(builtin, "tar", None, &e.to_string()))?
.to_string_lossy()
.into_owned();
let ty = entry.header().entry_type();
let target = if ty.is_symlink() || ty.is_hard_link() {
entry
.link_name()
.map_err(|e| crate::safety::tool_failed(builtin, "tar", None, &e.to_string()))?
.map(|p| p.to_string_lossy().into_owned())
} else {
None
};
out.push((name, target));
}
Ok((out, declared))
}
fn bi_zip_extract(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let archive = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"zip_extract",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let dest = args
.get(1)
.and_then(|v| match v {
Value::Str(s) => Some(s.clone()),
_ => None,
})
.unwrap_or(".".to_string());
// Extraction writes every member of the archive, which is not known until
// it runs, so the journal cannot snapshot them.
refuse_in_transaction("zip_extract", "extracting an archive cannot be rolled back")?;
// Refuse a member that would land outside the destination before the tool
// writes anything.
let (members, declared) = zip_archive_entries("zip_extract", &archive)?;
check_archive_entries("zip_extract", members.iter().cloned())?;
check_archive_budget("zip_extract", declared)?;
#[cfg(target_os = "windows")]
{
let dest = guard_local_write("zip_extract", &dest)?;
check_archive_protected("zip_extract", &dest, &members)?;
let cmd = crate::ps_script!(
"Expand-Archive -Path {} -DestinationPath {}",
crate::safety::ps_quote(&archive),
crate::safety::ps_quote(&dest)
);
let mut bounded_cmd = crate::safety::child_command("powershell");
bounded_cmd.args(["-Command", &cmd]);
// Archives can be large; extraction is bounded by the long deadline.
let output = bounded_cmd_output(
"zip_extract",
"powershell",
bounded_cmd,
TOOL_LONG_DEADLINE_SECS,
)?;
if !output.status.success() {
return Err(crate::safety::tool_failed(
"zip_extract",
"Expand-Archive",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
Ok(Value::Bool(true))
}
#[cfg(not(target_os = "windows"))]
{
crate::safety::reject_option_like("zip_extract", std::slice::from_ref(&archive))?;
crate::safety::reject_option_like("zip_extract", std::slice::from_ref(&dest))?;
let dest = guard_local_write("zip_extract", &dest)?;
check_archive_protected("zip_extract", &dest, &members)?;
let mut cmd = crate::safety::child_command("unzip");
cmd.args([&archive, "-d", &dest]);
// Archives can be large; extraction is bounded by the long deadline.
let output = bounded_cmd_output("zip_extract", "unzip", cmd, TOOL_LONG_DEADLINE_SECS)?;
if !output.status.success() {
return Err(crate::safety::tool_failed(
"zip_extract",
"unzip",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
return Ok(Value::Bool(true));
}
}
// 333: bi_zip_add - Add or refresh files in an existing zip archive.
//
// The archive must already exist; a missing one is E_NOT_FOUND, not a new file.
// The Unix zip is run with -u, so only new or newer files are written.
fn bi_zip_add(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("zip_add", &input)?;
let archive = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"zip_add",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let files = zip_file_args("zip_add", &args)?;
crate::safety::reject_option_like("zip_add", std::slice::from_ref(&archive))?;
crate::safety::reject_option_like("zip_add", &files)?;
let archive = guard_local_write("zip_add", &crate::safety::resolve_path_str(&archive))?;
if !std::path::Path::new(&archive).is_file() {
return Err(crate::safety::not_found("zip_add", "archive", &archive));
}
let files: Vec<String> = files
.iter()
.map(|f| crate::safety::resolve_path_str(f))
.collect();
// `zip -u` rewrites the one existing archive in place.
crate::tx::snapshot(&archive);
let (tool, cmd) = zip_command(&archive, &files, true);
run_checked("zip_add", tool, cmd, LOCAL_DEADLINE_SECS)?;
Ok(Value::Bool(true))
}
// The archive is written to a new file: tar streams it to stdout (`-f -`) and
// the bytes go through `tool_to_new_file`. That refuses an existing archive
// instead of truncating it, jails the destination, caps the size and the
// deadline, and removes a partial archive on failure. The member list is not
// echoed (no `-v`), so the output is not unbounded.
//
// Members are archived as links, not followed: tar is run without `-h` and
// `--dereference`. Under an enforced workspace every member must lie inside it,
// so a symlink an argument reaches cannot pull in a file from outside.
fn bi_tar_create(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("tar_create", &input)?;
let archive = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"tar_create",
"String",
other.map_or("nothing", |v| v.type_name()),
))
}
};
if args.len() < 2 {
return Err(crate::safety::bad_arg(
"tar_create",
"an archive path, then at least one member path",
"no members",
));
}
let mut files: Vec<String> = Vec::with_capacity(args.len() - 1);
for (i, v) in args.iter().enumerate().skip(1) {
match v {
Value::Str(s) => files.push(s.clone()),
other => {
return Err(crate::safety::bad_arg(
"tar_create",
&format!("args[{i}]: a member path string"),
other.type_name(),
))
}
}
}
// Every name goes to tar positionally, so a leading `-` is an option.
refuse_option("tar_create", &archive)?;
crate::safety::reject_option_like("tar_create", &files)?;
let enforce = crate::safety::current_mode() == crate::safety::Mode::Agent
|| std::env::var("IRONSHELL_WORKSPACE").is_ok();
let members: Vec<String> = files
.iter()
.map(|f| crate::safety::resolve_path_str(f))
.collect();
for m in &members {
if enforce && !crate::safety::within_workspace(m) {
return Err(crate::safety::policy_deny(
"tar_create",
format!("member '{m}' is outside the workspace"),
"archive only paths inside the workspace",
));
}
}
let out = guard_local_write("tar_create", &archive)?;
let mut argv: Vec<&str> = vec!["-c", "-f", "-", "--"];
argv.extend(members.iter().map(String::as_str));
tool_to_new_file("tar_create", "tar", &argv, &out)?;
Ok(Value::Bool(true))
}
fn bi_tar_extract(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let archive = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"tar_extract",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let dest = args.get(1).and_then(|v| match v {
Value::Str(s) => Some(s.clone()),
_ => None,
});
crate::safety::reject_option_like("tar_extract", std::slice::from_ref(&archive))?;
// Extraction writes every member of the archive, which is not known until
// it runs, so the journal cannot snapshot them.
refuse_in_transaction("tar_extract", "extracting an archive cannot be rolled back")?;
// Refuse a member, or a link, that would land outside the destination before
// tar writes anything.
let (members, declared) = tar_archive_listing("tar_extract", &archive)?;
check_archive_entries("tar_extract", members.iter().cloned())?;
check_archive_budget("tar_extract", declared)?;
let mut cmd = crate::safety::child_command("tar");
cmd.args(["-xvf", &archive]);
if let Some(d) = dest {
crate::safety::reject_option_like("tar_extract", std::slice::from_ref(&d))?;
// The central check judges only paths that already exist. A `-C`
// target usually does, but not always, so the destination is jailed
// here where its meaning is known.
let d = guard_local_write("tar_extract", &d)?;
check_archive_protected("tar_extract", &d, &members)?;
cmd.args(["-C", &d]);
} else {
check_archive_protected("tar_extract", ".", &members)?;
}
// Archives can be large; extraction is bounded by the long deadline.
let output = bounded_cmd_output("tar_extract", "tar", cmd, TOOL_LONG_DEADLINE_SECS)?;
if !output.status.success() {
return Err(crate::safety::tool_failed(
"tar_extract",
"tar",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
Ok(Value::Bool(true))
}
/// What an archive's name says it is: "zip", "tar", "tar.gz", "gzip",
/// "bzip2", "xz", "zstd", or None.
fn archive_kind(path: &str) -> Option<&'static str> {
let p = path.to_ascii_lowercase();
Some(if p.ends_with(".zip") {
"zip"
} else if p.ends_with(".tar.gz") || p.ends_with(".tgz") {
"tar.gz"
} else if p.ends_with(".tar") {
"tar"
} else if p.ends_with(".gz") {
"gzip"
} else if p.ends_with(".bz2") {
"bzip2"
} else if p.ends_with(".xz") {
"xz"
} else if p.ends_with(".zst") {
"zstd"
} else {
return None;
})
}
fn archive_path_arg(builtin: &str, args: &[Value]) -> Result<String> {
match args.first() {
Some(Value::Str(s)) => Ok(s.clone()),
other => Err(crate::safety::bad_arg(
builtin,
"path: String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
)),
}
}
fn archive_open(builtin: &str, path: &str) -> Result<std::fs::File> {
std::fs::File::open(path).map_err(|e| crate::safety::fs_error(builtin, path, &e))
}
fn archive_corrupt(builtin: &str, path: &str, e: impl std::fmt::Display) -> anyhow::Error {
crate::safety::bad_arg(builtin, "a readable archive", &format!("{path}: {e}"))
}
/// The entries of a tar or tar.gz archive, as {path, size, kind}.
fn tar_entries(builtin: &str, path: &str, gz: bool) -> Result<Vec<Value>> {
let f = archive_open(builtin, path)?;
let reader: Box<dyn std::io::Read> = if gz {
Box::new(flate2::read::GzDecoder::new(f))
} else {
Box::new(f)
};
let mut ar = tar::Archive::new(reader);
let mut out = Vec::new();
for entry in ar
.entries()
.map_err(|e| archive_corrupt(builtin, path, e))?
{
let entry = entry.map_err(|e| archive_corrupt(builtin, path, e))?;
let h = entry.header();
// A header or name that cannot be read used to become an entry with an
// empty path and size 0. The archive is refused instead.
let entry_path = entry
.path()
.map_err(|e| archive_corrupt(builtin, path, e))?
.to_string_lossy()
.to_string();
let size = h.size().map_err(|e| archive_corrupt(builtin, path, e))?;
let mut r = BTreeMap::new();
r.insert("path".to_string(), Value::Str(entry_path));
r.insert("size".to_string(), Value::Int(size as i64));
let kind = match h.entry_type() {
tar::EntryType::Directory => "dir",
tar::EntryType::Symlink | tar::EntryType::Link => "link",
_ => "file",
};
r.insert("kind".to_string(), Value::Str(kind.to_string()));
out.push(Value::Record(r));
}
Ok(out)
}
fn bi_tar_list(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// In-process. This returned `tar -tvf`'s text verbatim and ignored its
// exit status, so a missing or damaged archive answered "".
let path = archive_path_arg("tar_list", &args)?;
let gz = matches!(archive_kind(&path), Some("tar.gz"));
Ok(Value::Array(tar_entries("tar_list", &path, gz)?))
}
fn bi_zip_list(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// In-process. The Unix branch parsed `unzip -l` and dropped every line
// containing "files" -- meant for the summary line, it also removed
// entries such as src/files.rs -- and every failure answered [].
let path = archive_path_arg("zip_list", &args)?;
let mut z = zip::ZipArchive::new(archive_open("zip_list", &path)?)
.map_err(|e| archive_corrupt("zip_list", &path, e))?;
let mut out = Vec::new();
for i in 0..z.len() {
let f = z
.by_index_raw(i)
.map_err(|e| archive_corrupt("zip_list", &path, e))?;
let mut r = BTreeMap::new();
let name = f
.name()
.map(|n| n.into_owned())
.unwrap_or_else(|_| String::from_utf8_lossy(f.name_raw()).into_owned());
r.insert("name".to_string(), Value::Str(name));
r.insert("size".to_string(), Value::Int(f.size() as i64));
r.insert(
"compressed_size".to_string(),
Value::Int(f.compressed_size() as i64),
);
r.insert("dir".to_string(), Value::Bool(f.is_dir()));
out.push(Value::Record(r));
}
Ok(Value::Array(out))
}
fn bi_archive_info(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// A file that did not exist still answered {path, type}.
let path = archive_path_arg("archive_info", &args)?;
let meta =
std::fs::metadata(&path).map_err(|e| crate::safety::fs_error("archive_info", &path, &e))?;
let mut rec = BTreeMap::new();
rec.insert("path".to_string(), Value::Str(path.clone()));
rec.insert(
"type".to_string(),
archive_kind(&path)
.map(|k| Value::Str(k.to_string()))
.unwrap_or(Value::Null),
);
rec.insert("size".to_string(), Value::Int(meta.len() as i64));
Ok(Value::Record(rec))
}
#[path = "archive_cli.rs"]
mod archive_cli;
fn bi_archive_test(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
// Whether every entry decompresses and, for zip, matches its CRC. This
// answered `true` for any zip on Windows without testing it, and for
// any other format whenever the file merely existed.
if args.len() != 1 {
return Err(crate::safety::bad_arg(
"archive_test",
"exactly one path",
&format!("{} arguments", args.len()),
));
}
if input.is_some() {
return Err(crate::safety::bad_arg(
"archive_test",
"a path argument",
"pipeline input; archive_test takes no piped value",
));
}
let path = archive_path_arg("archive_test", &args)?;
if let Some((codec, tar)) = archive_cli::named(&path) {
// bzip2, xz and zstd, and the tar archives inside them: the standard
// tools, checked against the file's magic bytes first.
return Ok(Value::Bool(archive_cli::test(
"archive_test",
&path,
codec,
tar,
)?));
}
// bzip2, xz and zstd names were taken by `archive_cli::named` above, so
// only the in-process formats reach this match.
let kind = match archive_kind(&path) {
Some("zip") => LocalArchive::Zip,
Some("tar") => LocalArchive::Tar,
Some("tar.gz") => LocalArchive::TarGz,
Some("gzip") => LocalArchive::Gzip,
_ => {
return Err(crate::safety::bad_arg(
"archive_test",
"a .zip, .tar, .tar.gz/.tgz or .gz file",
&path,
))
}
};
Ok(Value::Bool(archive_test_local(
&path,
kind,
archive_cli::DECOMPRESSED_CAP,
)?))
}
/// The formats `archive_test_local` reads in-process. bzip2, xz and zstd are
/// tested by `archive_cli`, which `bi_archive_test` asks first.
#[derive(Clone, Copy)]
enum LocalArchive {
Zip,
Tar,
TarGz,
Gzip,
}
impl LocalArchive {
fn label(self) -> &'static str {
match self {
LocalArchive::Zip => "zip",
LocalArchive::Tar => "tar",
LocalArchive::TarGz => "tar.gz",
LocalArchive::Gzip => "gzip",
}
}
}
/// archive_test for the formats read in-process (zip, tar, tar.gz, gzip),
/// with at most `cap` decompressed bytes. The tool-backed formats had a cap
/// and these did not, so a small archive that inflates to terabytes was read
/// to its end; past the cap the answer is E_BUDGET_EXCEEDED, neither true nor
/// false.
fn archive_test_local(path: &str, kind: LocalArchive, cap: u64) -> Result<bool> {
use std::cell::Cell;
struct CappedSink<'a> {
left: &'a Cell<u64>,
hit: &'a Cell<bool>,
}
impl std::io::Write for CappedSink<'_> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let left = self.left.get();
if buf.len() as u64 > left {
self.hit.set(true);
return Err(std::io::Error::other("decompressed size cap reached"));
}
self.left.set(left - buf.len() as u64);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let left = Cell::new(cap);
let hit = Cell::new(false);
let sink = |r: &mut dyn std::io::Read| {
let mut out = CappedSink {
left: &left,
hit: &hit,
};
std::io::copy(r, &mut out).is_ok()
};
let ok = match kind {
LocalArchive::Zip => {
let Ok(mut z) = zip::ZipArchive::new(archive_open("archive_test", path)?) else {
return Ok(false);
};
(0..z.len()).all(|i| z.by_index(i).map(|mut f| sink(&mut f)).unwrap_or(false))
}
LocalArchive::Tar | LocalArchive::TarGz => {
let f = archive_open("archive_test", path)?;
let reader: Box<dyn std::io::Read> = if matches!(kind, LocalArchive::TarGz) {
Box::new(flate2::read::GzDecoder::new(f))
} else {
Box::new(f)
};
let mut ar = tar::Archive::new(reader);
match ar.entries() {
Ok(entries) => entries
.into_iter()
.all(|e| e.map(|mut e| sink(&mut e)).unwrap_or(false)),
Err(_) => false,
}
}
LocalArchive::Gzip => sink(&mut flate2::read::GzDecoder::new(archive_open(
"archive_test",
path,
)?)),
};
if hit.get() {
return Err(crate::safety::budget_exceeded(
"archive_test",
format!(
"the decompressed {} contents passed {} bytes",
kind.label(),
cap
),
"the archive is too large to test in-process; it is neither confirmed nor refuted",
));
}
Ok(ok)
}
#[cfg(test)]
mod archive_test_cap_tests {
use super::*;
use std::io::Write as _;
#[test]
fn an_archive_past_the_cap_is_budget_exceeded_not_false() {
let dir = std::env::temp_dir().join(format!("ae-archcap-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("zeros.gz");
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best());
gz.write_all(&vec![0u8; 1 << 20]).unwrap();
std::fs::write(&path, gz.finish().unwrap()).unwrap();
let p = path.to_string_lossy().to_string();
assert!(archive_test_local(&p, LocalArchive::Gzip, 1 << 20).unwrap());
let e = archive_test_local(&p, LocalArchive::Gzip, (1 << 20) - 1).unwrap_err();
let code = e
.downcast_ref::<crate::safety::SafetyError>()
.map(|s| s.code);
assert_eq!(code, Some(crate::safety::ErrorCode::BudgetExceeded), "{e}");
std::fs::remove_dir_all(&dir).unwrap();
}
}
/// gzip, in-process (flate2): `gzip -k` semantics -- the input is kept and
/// an existing output is not overwritten. This ran the gzip program, which
/// Windows does not have, passed the file name unchecked (a leading `-` was
/// an option), and answered a bare `false` for every failure.
///
/// `out_path` must already have been through guard_local_write in the
/// builtin's own body: tests/guard_enforcement.rs reads `bi_*` bodies, not
/// helpers, to decide which builtins guard themselves.
fn gzip_file(builtin: &str, input: &str, out_path: &str, decompress: bool) -> Result<Value> {
gzip_file_capped(builtin, input, out_path, decompress, FILE_OUTPUT_CAP)
}
/// [`gzip_file`] with the decompressed-size cap given. A decompression that
/// would write more than `cap` bytes is stopped before the write, refused as
/// E_BUDGET_EXCEEDED, and its partial output removed. Every failure removes the
/// output file, so none is left behind.
fn gzip_file_capped(
builtin: &str,
input: &str,
out_path: &str,
decompress: bool,
cap: u64,
) -> Result<Value> {
let src =
std::fs::File::open(input).map_err(|e| crate::safety::fs_error(builtin, input, &e))?;
// The exclusive create is the existence check: a file that appears after an
// exists() test would otherwise be truncated, and removed on failure.
let dst = create_new_output(builtin, out_path)?;
let result = if decompress {
let mut out = std::io::BufWriter::new(dst);
gunzip_capped(flate2::read::GzDecoder::new(src), &mut out, cap)
} else {
let mut enc = flate2::write::GzEncoder::new(dst, flate2::Compression::default());
std::io::copy(&mut std::io::BufReader::new(src), &mut enc)
.and_then(|_| enc.finish())
.map(|_| true)
};
match result {
Ok(true) => Ok(Value::Bool(true)),
Ok(false) => {
// The writer is closed by now (it is local to the branch above), so
// the partial file can be removed on every platform.
Err(fail_removing_partial(
crate::safety::budget_exceeded(
builtin,
format!("{input} decompresses to more than {cap} bytes and was stopped"),
"the output would be cut short, so it is refused and the partial output is removed; check that the archive is the one you meant",
),
out_path,
))
}
Err(e) => {
// no half-written output
Err(fail_removing_partial(
crate::safety::bad_arg(
builtin,
if decompress {
"valid gzip data"
} else {
"a readable file"
},
&format!("{input}: {e}"),
),
out_path,
))
}
}
}
/// Copies the gzip stream `src` into `dst`, flushing at the end. Returns
/// `Ok(false)` as soon as the output would pass `cap`; the chunk that would have
/// passed it is not written. Every read consumes input, so the loop ends.
fn gunzip_capped<R: std::io::Read, W: std::io::Write>(
mut src: R,
dst: &mut W,
cap: u64,
) -> std::io::Result<bool> {
let mut chunk = [0u8; 8192];
let mut total: u64 = 0;
loop {
let n = match src.read(&mut chunk) {
Ok(0) => break,
Ok(n) => n,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
};
total = total.saturating_add(n as u64);
if total > cap {
return Ok(false);
}
dst.write_all(&chunk[..n])?;
}
dst.flush()?;
Ok(true)
}
/// Test hook: `gzip_decompress` of `file` with a decompressed-size cap the test
/// chooses (the builtin uses FILE_OUTPUT_CAP). The jail check still runs.
/// Not part of the public API.
#[doc(hidden)]
pub fn __test_gzip_decompress_capped(file: &str, cap: u64) -> Result<Value> {
let out = guard_local_write(
"gzip_decompress",
&gzip_out_path("gzip_decompress", file, true)?,
)?;
gzip_file_capped("gzip_decompress", file, &out, true, cap)
}
/// Where gzip_file writes: path.gz, or the path without its .gz.
fn gzip_out_path(builtin: &str, input: &str, decompress: bool) -> Result<String> {
Ok(if decompress {
input
.strip_suffix(".gz")
.ok_or_else(|| crate::safety::bad_arg(builtin, "a .gz file", input))?
.to_string()
} else {
format!("{input}.gz")
})
}
fn bi_gzip_compress(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let file = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"gzip_compress",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let out = guard_local_write(
"gzip_compress",
&gzip_out_path("gzip_compress", &file, false)?,
)?;
crate::tx::snapshot(&out); // the one output file; absent until written
gzip_file("gzip_compress", &file, &out, false)
}
fn bi_gzip_decompress(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let file = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"gzip_decompress",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let out = guard_local_write(
"gzip_decompress",
&gzip_out_path("gzip_decompress", &file, true)?,
)?;
crate::tx::snapshot(&out); // the one output file; absent until written
gzip_file("gzip_decompress", &file, &out, true)
}
fn bi_bzip2_compress(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let file = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"bzip2_compress",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
// The file goes to bzip2 positionally; a leading `-` would be an option.
crate::safety::reject_option_like("bzip2_compress", std::slice::from_ref(&file))?;
// bzip2 writes `<file>.bz2` beside its input. That output is jailed as
// gzip_compress's is; the input is resolved the same way, so the file
// bzip2 writes is the one the jail judged.
let file = crate::safety::resolve_path_str(&file);
let out = guard_local_write("bzip2_compress", &format!("{file}.bz2"))?;
crate::tx::snapshot(&out); // absent until written: bzip2 will not replace it
let mut cmd = crate::safety::child_command("bzip2");
cmd.args(["-k", &file]);
// Compressing a large file can outlast the short deadlines.
let output = bounded_cmd_output("bzip2_compress", "bzip2", cmd, TOOL_LONG_DEADLINE_SECS)?;
// A failure was a bare `false`, with bzip2's reason discarded.
if !output.status.success() {
return Err(crate::safety::tool_failed(
"bzip2_compress",
"bzip2",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
Ok(Value::Bool(true))
}
// The decompressed stream goes to a new file beside the archive, not in place,
// so bzip2 is run with -c and the output is created exclusively. That lets the
// output be jailed to the workspace (the in-place form wrote wherever the
// archive was, with no check), refuses an existing file, and caps the bytes
// written (a small archive can inflate without bound). A run past the cap or the
// deadline is stopped and its partial file removed.
fn bi_bzip2_decompress(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("bzip2_decompress", &input)?;
if args.len() != 1 {
return Err(crate::safety::bad_arg(
"bzip2_decompress",
"one path to a .bz2 file",
&format!("{} arguments", args.len()),
));
}
let file = match &args[0] {
Value::Str(s) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"bzip2_decompress",
"String",
other.type_name(),
))
}
};
// The file goes to bzip2 positionally; a leading `-` would be an option.
refuse_option("bzip2_decompress", &file)?;
let out = file
.strip_suffix(".bz2")
.ok_or_else(|| crate::safety::bad_arg("bzip2_decompress", "a .bz2 file", &file))?
.to_string();
let input = crate::safety::resolve_path_str(&file);
regular_file_arg("bzip2_decompress", &input)?;
let out = guard_local_write("bzip2_decompress", &out)?;
tool_to_new_file(
"bzip2_decompress",
"bzip2",
&["-d", "-c", input.as_str()],
&out,
)?;
Ok(Value::Bool(true))
}
// 341: bi_xz_compress - Write file.xz beside a file, keeping the file.
//
// The compressed stream goes to a new file created exclusively, so an existing
// file.xz is refused and nothing is overwritten.
fn bi_xz_compress(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("xz_compress", &input)?;
let file = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"xz_compress",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
refuse_option("xz_compress", &file)?;
let input = crate::safety::resolve_path_str(&file);
regular_file_arg("xz_compress", &input)?;
let out = guard_local_write("xz_compress", &format!("{input}.xz"))?;
tool_to_new_file("xz_compress", "xz", &["-z", "-c", input.as_str()], &out)?;
Ok(Value::Bool(true))
}
// 342: bi_xz_decompress - Write a .xz file's contents beside it, without the suffix.
//
// The output is capped at FILE_OUTPUT_CAP bytes. A stream that inflates past it
// is stopped and its partial file removed. An existing output is refused.
fn bi_xz_decompress(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("xz_decompress", &input)?;
let file = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"xz_decompress",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
refuse_option("xz_decompress", &file)?;
let out = file
.strip_suffix(".xz")
.ok_or_else(|| crate::safety::bad_arg("xz_decompress", "a .xz file", &file))?
.to_string();
let input = crate::safety::resolve_path_str(&file);
regular_file_arg("xz_decompress", &input)?;
let out = guard_local_write("xz_decompress", &out)?;
tool_to_new_file("xz_decompress", "xz", &["-d", "-c", input.as_str()], &out)?;
Ok(Value::Bool(true))
}
// 343: bi_zstd_compress - Write file.zst beside a file, keeping the file.
fn bi_zstd_compress(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("zstd_compress", &input)?;
let file = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"zstd_compress",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
refuse_option("zstd_compress", &file)?;
let input = crate::safety::resolve_path_str(&file);
regular_file_arg("zstd_compress", &input)?;
let out = guard_local_write("zstd_compress", &format!("{input}.zst"))?;
tool_to_new_file("zstd_compress", "zstd", &["-q", "-c", input.as_str()], &out)?;
Ok(Value::Bool(true))
}
// 344: bi_zstd_decompress - Write a .zst file's contents beside it, without the suffix.
//
// Bounded and refused on an existing output, exactly as xz_decompress is.
fn bi_zstd_decompress(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("zstd_decompress", &input)?;
let file = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"zstd_decompress",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
refuse_option("zstd_decompress", &file)?;
let out = file
.strip_suffix(".zst")
.ok_or_else(|| crate::safety::bad_arg("zstd_decompress", "a .zst file", &file))?
.to_string();
let input = crate::safety::resolve_path_str(&file);
regular_file_arg("zstd_decompress", &input)?;
let out = guard_local_write("zstd_decompress", &out)?;
tool_to_new_file(
"zstd_decompress",
"zstd",
&["-d", "-q", "-c", input.as_str()],
&out,
)?;
Ok(Value::Bool(true))
}
// ============================================================================
// 350-369: User & Permission Management
// ============================================================================
fn bi_user_list(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
bi_sys_users(vec![], None)
}
#[path = "accounts.rs"]
mod accounts;
fn bi_user_add(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
accounts::user_add(args, input)
}
fn bi_user_del(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
accounts::user_del(args, input)
}
fn bi_user_mod(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
accounts::user_mod(args, input)
}
fn bi_user_passwd(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
accounts::user_passwd(args, input)
}
fn bi_user_lock(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
accounts::user_lock(args, input)
}
fn bi_user_unlock(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
accounts::user_unlock(args, input)
}
fn bi_group_list(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
bi_sys_groups(vec![], None)
}
fn bi_group_add(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
accounts::group_add(args, input)
}
fn bi_group_del(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
accounts::group_del(args, input)
}
fn bi_group_mod(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
accounts::group_mod(args, input)
}
fn bi_group_members(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let group = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"group_members",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
crate::safety::reject_option_like("group_members", std::slice::from_ref(&group))?;
#[cfg(target_os = "windows")]
{
let mut cmd = crate::safety::child_command("powershell");
cmd.args([
"-Command",
&crate::ps_script!(
"Get-LocalGroupMember {} | Select-Object Name | ConvertTo-Json",
crate::safety::ps_quote(&group)
),
]);
let output = bounded_cmd_output("group_members", "powershell", cmd, PROBE_DEADLINE_SECS)?;
if output.status.success() {
let json_str = String::from_utf8_lossy(&output.stdout);
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&json_str) {
let items = match &json {
serde_json::Value::Array(arr) => arr.clone(),
obj @ serde_json::Value::Object(_) => vec![obj.clone()],
_ => vec![],
};
let members: Vec<Value> = items
.iter()
.filter_map(|obj| {
obj.as_object()?
.get("Name")?
.as_str()
.map(|s| Value::Str(s.to_string()))
})
.collect();
return Ok(Value::Array(members));
}
}
return Err(crate::safety::tool_failed(
"group_members",
"Get-LocalGroupMember",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
#[cfg(not(target_os = "windows"))]
{
let mut cmd = crate::safety::child_command("getent");
cmd.args(["group", &group]);
let output = bounded_cmd_output("group_members", "getent", cmd, PROBE_DEADLINE_SECS)?;
if output.status.success() {
let line = String::from_utf8_lossy(&output.stdout);
if let Some(members) = line.split(':').nth(3) {
let member_list: Vec<Value> = members
.trim()
.split(',')
.filter(|s| !s.is_empty())
.map(|s| Value::Str(s.to_string()))
.collect();
return Ok(Value::Array(member_list));
}
}
// getent exits 2 for a name it does not know. An unknown group, a
// failed query and a group with no members all answered [].
if output.status.code() == Some(2) {
return Err(crate::safety::not_found("group_members", "group", &group));
}
Err(crate::safety::tool_failed(
"group_members",
"getent",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
))
}
}
fn bi_perm_get(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"perm_get",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
// A path that could not be read was null.
let meta =
std::fs::metadata(&path).map_err(|e| crate::safety::fs_error("perm_get", &path, &e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = meta.permissions().mode();
return Ok(Value::Str(format!("{:o}", mode & 0o777)));
}
#[cfg(not(unix))]
{
let readonly = meta.permissions().readonly();
Ok(Value::Str(
if readonly { "readonly" } else { "readwrite" }.to_string(),
))
}
}
fn bi_perm_set(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
guard_and_set_permission_mode("perm_set", args, input)
}
fn bi_perm_check(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
if !(1..=2).contains(&args.len()) || input.is_some() {
return Err(crate::safety::arg_err(
"perm_check requires path and optional mode, without pipeline input",
));
}
let mut args = args.into_iter();
let Some(Value::Str(path)) = args.next() else {
return Err(crate::safety::arg_err("perm_check path must be String"));
};
let mode = match args.next() {
None => "r".to_string(),
Some(Value::Str(mode))
if matches!(
mode.as_str(),
"r" | "read" | "w" | "write" | "x" | "execute"
) =>
{
mode
}
_ => {
return Err(crate::safety::arg_err(
"perm_check mode must be r/read, w/write or x/execute",
))
}
};
let path = crate::safety::resolve_path_str(&path);
let metadata =
fs::metadata(&path).map_err(|e| crate::safety::fs_error("perm_check", &path, &e))?;
// This is a metadata query, not an access check for the caller's identity,
// ACLs, privileges, mount flags or sharing restrictions.
match mode.as_str() {
"r" | "read" => Ok(Value::Bool(true)),
"w" | "write" => Ok(Value::Bool(!metadata.permissions().readonly())),
"x" | "execute" => {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
Ok(Value::Bool(metadata.permissions().mode() & 0o111 != 0))
}
#[cfg(windows)]
{
Ok(Value::Bool(
metadata.is_file() && windows_pathext_executable(std::path::Path::new(&path)),
))
}
#[cfg(not(any(unix, windows)))]
{
Err(crate::safety::unimplemented(
"perm_check",
"execute permission bits are unavailable on this platform",
"inspect platform-specific executable formats and access controls",
))
}
}
_ => unreachable!("validated permission mode"),
}
}
/// Windows executability is a naming rule, not an ACL check: a regular file
/// whose extension appears in PATHEXT (default `.COM;.EXE;.BAT;.CMD`).
#[cfg(windows)]
fn windows_pathext_executable(path: &std::path::Path) -> bool {
let Some(extension) = path.extension().and_then(|e| e.to_str()) else {
return false;
};
let pathext = std::env::var("PATHEXT")
.ok()
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| ".COM;.EXE;.BAT;.CMD".to_string());
pathext
.split(';')
.map(|entry| entry.trim().trim_start_matches('.'))
.filter(|entry| !entry.is_empty())
.any(|entry| entry.eq_ignore_ascii_case(extension))
}
#[path = "acl_mount.rs"]
mod acl_mount;
fn bi_acl_get(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
acl_mount::acl_get(args, input)
}
fn bi_acl_set(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
acl_mount::acl_set(args, input)
}
fn bi_sudo_check(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
if !args.is_empty() || input.is_some() {
return Err(crate::safety::arg_err(
"sudo_check accepts no arguments or pipeline input",
));
}
#[cfg(windows)]
{
Ok(Value::Bool(windows_is_admin("sudo_check")?))
}
#[cfg(unix)]
{
// geteuid cannot fail and inspects this process without spawning id.
Ok(Value::Bool(unsafe { libc::geteuid() } == 0))
}
#[cfg(not(any(unix, windows)))]
{
Err(crate::safety::unimplemented(
"sudo_check",
"privilege inspection is unavailable on this platform",
"use the platform's identity inspection tools",
))
}
}
fn bi_sudo_exec(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
services::sudo_exec(args, input)
}
/// Capability numbers from `linux/capability.h`, in order. A bit beyond the
/// end of this table (a newer kernel) is reported as `cap_<n>`, not dropped.
#[cfg(target_os = "linux")]
const LINUX_CAPABILITIES: [&str; 41] = [
"cap_chown",
"cap_dac_override",
"cap_dac_read_search",
"cap_fowner",
"cap_fsetid",
"cap_kill",
"cap_setgid",
"cap_setuid",
"cap_setpcap",
"cap_linux_immutable",
"cap_net_bind_service",
"cap_net_broadcast",
"cap_net_admin",
"cap_net_raw",
"cap_ipc_lock",
"cap_ipc_owner",
"cap_sys_module",
"cap_sys_rawio",
"cap_sys_chroot",
"cap_sys_ptrace",
"cap_sys_pacct",
"cap_sys_admin",
"cap_sys_boot",
"cap_sys_nice",
"cap_sys_resource",
"cap_sys_time",
"cap_sys_tty_config",
"cap_mknod",
"cap_lease",
"cap_audit_write",
"cap_audit_control",
"cap_setfcap",
"cap_mac_override",
"cap_mac_admin",
"cap_syslog",
"cap_wake_alarm",
"cap_block_suspend",
"cap_audit_read",
"cap_perfmon",
"cap_bpf",
"cap_checkpoint_restore",
];
#[cfg(target_os = "linux")]
fn decode_capability_mask(hex: &str) -> Option<Vec<Value>> {
let mask = u64::from_str_radix(hex.trim(), 16).ok()?;
Some(
(0..64u32)
.filter(|bit| mask & (1u64 << bit) != 0)
.map(|bit| {
Value::Str(match LINUX_CAPABILITIES.get(bit as usize) {
Some(name) => name.to_string(),
None => format!("cap_{bit}"),
})
})
.collect(),
)
}
fn bi_capabilities(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// This parsed `capsh --print`, and got it wrong: capsh writes the
// effective set as `Current: =...`, with a colon, so the set an agent
// most needs was never extracted (it surfaced as a key named
// "current:"), `uid` came back as the string "1000(test) euid=1000(test)",
// and hosts without libcap's tools failed outright. The kernel publishes
// all five sets in /proc/self/status as bitmasks; decode those.
#[cfg(target_os = "linux")]
{
let status = std::fs::read_to_string("/proc/self/status")
.map_err(|e| crate::safety::fs_error("capabilities", "/proc/self/status", &e))?;
let mut rec = std::collections::BTreeMap::new();
for line in status.lines() {
let Some((key, val)) = line.split_once(':') else {
continue;
};
let field = match key {
"CapInh" => "inheritable",
"CapPrm" => "permitted",
"CapEff" => "effective",
"CapBnd" => "bounding",
"CapAmb" => "ambient",
"NoNewPrivs" => {
rec.insert("no_new_privs".to_string(), Value::Bool(val.trim() == "1"));
continue;
}
"Uid" | "Gid" => {
// real, effective, saved, filesystem
let ids: Vec<i64> = val
.split_whitespace()
.filter_map(|v| v.parse().ok())
.collect();
let (real, eff) = if key == "Uid" {
("uid", "euid")
} else {
("gid", "egid")
};
if let [r, e, ..] = ids[..] {
rec.insert(real.to_string(), Value::Int(r));
rec.insert(eff.to_string(), Value::Int(e));
}
continue;
}
_ => continue,
};
let caps = decode_capability_mask(val).ok_or_else(|| {
crate::safety::bad_state(
"capabilities",
&format!(
"/proc/self/status has an unreadable {key} mask: {}",
val.trim()
),
"this kernel reports capabilities in a format this shell does not know",
)
})?;
rec.insert(field.to_string(), Value::Array(caps));
}
for field in [
"inheritable",
"permitted",
"effective",
"bounding",
"ambient",
] {
if !rec.contains_key(field) {
return Err(crate::safety::bad_state(
"capabilities",
&format!("/proc/self/status does not report the {field} set"),
"this kernel predates the capability set, or /proc is not procfs",
));
}
}
return Ok(Value::Record(rec));
}
// Windows has no capability bitmasks; its privileges are listed by
// `whoami /priv`, a read-only system tool run without a shell.
#[cfg(windows)]
{
return windows_privileges();
}
// Linux capability sets are a Linux concept. Everywhere else this fell
// through to `Ok(Value::Null)`, which a caller reads as "this process
// has no capabilities" -- a claim about privilege, made without looking.
#[allow(unreachable_code)]
Err(crate::safety::unimplemented(
"capabilities",
"Linux capability sets do not exist on this operating system; NOTHING WAS INSPECTED",
"use `id` on this system",
))
}
/// The privileges of this Windows process, from `whoami /priv /fo csv`, as
/// `{privileges: [{name, description, state}]}`. The shape differs from the
/// Linux record because Windows has no capability masks.
#[cfg(windows)]
fn windows_privileges() -> Result<Value> {
let run = tool_run(
"capabilities",
"whoami",
None,
&["/priv", "/fo", "csv"],
Keep::Head(TOOL_MAX_TEXT),
TOOL_DEADLINE_SECS,
)?;
if !run.success {
return Err(crate::safety::tool_failed(
"capabilities",
&run.command,
run.code,
&run.stderr,
));
}
let rows = parse_whoami_priv_csv("capabilities", &run.stdout)?;
let mut rec = BTreeMap::new();
rec.insert("privileges".to_string(), Value::Array(rows));
Ok(Value::Record(rec))
}
/// Parses the CSV that `whoami /priv /fo csv` prints. Lines before the header
/// that have one field (a title) are skipped. The header must name the three
/// columns in English; any other layout is refused, not guessed at. Every line
/// after it must hold exactly three fields, or the whole answer is refused.
#[cfg(any(windows, test))]
fn parse_whoami_priv_csv(builtin: &str, text: &str) -> Result<Vec<Value>> {
const HEADER: [&str; 3] = ["Privilege Name", "Description", "State"];
let mut rows = Vec::new();
let mut seen_header = false;
for line in text.lines() {
let line = line.trim_end_matches('\r');
if line.trim().is_empty() {
continue;
}
let Some(fields) = csv_fields(line) else {
return Err(crate::safety::bad_state(
builtin,
&format!("whoami /priv printed a malformed CSV line: {line:?}"),
"this host's whoami output is not the CSV layout this shell reads",
));
};
if !seen_header {
if fields.len() == 1 {
continue;
}
if fields.iter().map(String::as_str).eq(HEADER) {
seen_header = true;
continue;
}
return Err(crate::safety::bad_state(
builtin,
&format!("whoami /priv printed an unexpected header: {line:?}"),
"the privilege table is read only in its English layout",
));
}
if fields.len() != 3 {
return Err(crate::safety::bad_state(
builtin,
&format!(
"whoami /priv printed a line with {} fields, not 3: {line:?}",
fields.len()
),
"this host's whoami output is not the CSV layout this shell reads",
));
}
let mut rec = BTreeMap::new();
rec.insert("name".to_string(), Value::Str(fields[0].clone()));
rec.insert("description".to_string(), Value::Str(fields[1].clone()));
rec.insert("state".to_string(), Value::Str(fields[2].clone()));
rows.push(Value::Record(rec));
}
if !seen_header {
return Err(crate::safety::bad_state(
builtin,
"whoami /priv printed no privilege table",
"this host's whoami output is not the CSV layout this shell reads",
));
}
Ok(rows)
}
/// Splits one CSV line into fields. A field may be quoted, and a quoted field
/// may hold commas and doubled quotes (""). None for an unterminated quote or
/// text between a closing quote and the next comma.
#[cfg(any(windows, test))]
fn csv_fields(line: &str) -> Option<Vec<String>> {
let mut fields = Vec::new();
let mut chars = line.chars().peekable();
loop {
let mut field = String::new();
if chars.peek() == Some(&'"') {
chars.next();
loop {
match chars.next() {
None => return None,
Some('"') => {
if chars.peek() == Some(&'"') {
chars.next();
field.push('"');
} else {
break;
}
}
Some(c) => field.push(c),
}
}
match chars.next() {
None => {
fields.push(field);
return Some(fields);
}
Some(',') => {}
Some(_) => return None,
}
} else {
loop {
match chars.next() {
None => {
fields.push(field);
return Some(fields);
}
Some(',') => break,
Some(c) => field.push(c),
}
}
}
fields.push(field);
}
}
#[cfg(test)]
mod whoami_csv_tests {
use super::*;
const SAMPLE: &str = "\"Privilege Name\",\"Description\",\"State\"\r\n\
\"SeShutdownPrivilege\",\"Shut down the system\",\"Disabled\"\r\n\
\"SeChangeNotifyPrivilege\",\"Bypass traverse checking, for any user\",\"Enabled\"\r\n\
\"SeUndockPrivilege\",\"Say \"\"undock\"\"\",\"Disabled\"\r\n";
fn field(v: &Value, key: &str) -> String {
match v {
Value::Record(r) => match r.get(key) {
Some(Value::Str(s)) => s.clone(),
other => panic!("{key}: {other:?}"),
},
other => panic!("not a record: {other:?}"),
}
}
#[test]
fn parses_names_descriptions_and_states_including_quoted_commas() {
let rows = parse_whoami_priv_csv("capabilities", SAMPLE).expect("sample parses");
assert_eq!(rows.len(), 3);
assert_eq!(field(&rows[0], "name"), "SeShutdownPrivilege");
assert_eq!(field(&rows[0], "state"), "Disabled");
assert_eq!(
field(&rows[1], "description"),
"Bypass traverse checking, for any user"
);
assert_eq!(field(&rows[2], "description"), "Say \"undock\"");
}
#[test]
fn skips_a_title_line_before_the_header() {
let text = format!("\"PRIVILEGES INFORMATION\"\n\n{SAMPLE}");
assert_eq!(
parse_whoami_priv_csv("capabilities", &text).unwrap().len(),
3
);
}
#[test]
fn a_malformed_line_refuses_the_whole_answer() {
let text = "\"Privilege Name\",\"Description\",\"State\"\n\
\"SeShutdownPrivilege\",\"Shut down the system\",\"Disabled\"\n\
\"SeBroken\",\"unterminated,\"Enabled\n";
let err = parse_whoami_priv_csv("capabilities", text).expect_err("malformed");
assert!(err.to_string().contains("E_BAD_STATE"), "{err}");
}
#[test]
fn a_line_with_the_wrong_field_count_refuses() {
let text = "\"Privilege Name\",\"Description\",\"State\"\n\"SeOnly\",\"Enabled\"\n";
let err = parse_whoami_priv_csv("capabilities", text).expect_err("two fields");
assert!(err.to_string().contains("E_BAD_STATE"), "{err}");
}
#[test]
fn a_localised_header_refuses_rather_than_guessing() {
let err = parse_whoami_priv_csv("capabilities", "\"Name\",\"Desc\",\"Status\"\n")
.expect_err("header");
assert!(err.to_string().contains("E_BAD_STATE"), "{err}");
}
#[test]
fn csv_fields_rejects_text_after_a_closing_quote() {
assert_eq!(csv_fields("\"a\"b,c"), None);
assert_eq!(
csv_fields("a,\"b,c\",d"),
Some(vec!["a".to_string(), "b,c".to_string(), "d".to_string()])
);
}
}
// ============================================================================
// 370-389: Package Management
// ============================================================================
fn detect_package_manager() -> &'static str {
#[cfg(target_os = "windows")]
{
"winget"
}
#[cfg(target_os = "macos")]
{
return "brew";
}
#[cfg(target_os = "linux")]
{
if std::path::Path::new("/usr/bin/apt").exists() {
return "apt";
}
if std::path::Path::new("/usr/bin/dnf").exists() {
return "dnf";
}
if std::path::Path::new("/usr/bin/yum").exists() {
return "yum";
}
if std::path::Path::new("/usr/bin/pacman").exists() {
return "pacman";
}
return "apt";
}
}
fn bi_pkg_list(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
if !args.is_empty() {
return Err(crate::safety::arg_err(format!(
"pkg_list takes no arguments, got {}",
args.len()
)));
}
linux_diagnostics::no_pipeline_input("pkg_list", &input)?;
let packages = match detect_package_manager() {
"apt" => pkg_query::list_dpkg("pkg_list")?,
"pacman" => pkg_query::list_pacman("pkg_list")?,
"dnf" | "yum" => pkg_query::list_rpm("pkg_list")?,
"brew" => pkg_query::list_brew("pkg_list")?,
#[cfg(windows)]
"winget" => pkg_query::list_windows_arp("pkg_list")?,
other => {
return Err(crate::safety::unimplemented(
"pkg_list",
&format!(
"pkg_list is not implemented for the {other} package manager in 14.0.0; NOTHING WAS LISTED"
),
"list the packages with that manager directly",
))
}
};
Ok(Value::Array(
packages
.into_iter()
.map(|(name, version)| {
let mut rec = std::collections::BTreeMap::new();
rec.insert("name".to_string(), Value::Str(name));
// Only a Windows program can record no version.
let version = if version.is_empty() {
Value::Null
} else {
Value::Str(version)
};
rec.insert("version".to_string(), version);
Value::Record(rec)
})
.collect(),
))
}
/// This package manager is not one the builtin knows how to query. These
/// answered [] ("no files", "no results"), null, or -- for pkg_verify --
/// `true`, "verified", without inspecting anything.
fn pkg_unsupported(builtin: &str, pm: &str) -> anyhow::Error {
crate::safety::unimplemented(
builtin,
&format!(
"{builtin} is not implemented for the {pm} package manager; NOTHING WAS INSPECTED"
),
"query the package manager directly",
)
}
/// Whether a nonzero exit from a package query is that tool's documented
/// "nothing matched" answer rather than a failure. Each rule is one tool's own
/// convention: `dpkg -S` and `dpkg-query -L` exit 1 when no package owns the
/// path or the package is not installed; `pacman -Ss` exits 1 with no output
/// when nothing matches; the pacman queries exit 1 with "was not found" or "No
/// package owns" on stderr; `apt-cache show|depends|rdepends` report "No
/// packages found". The text rules are English only: a localised message is
/// not recognised, so it is E_TOOL_FAILED, which still carries the stderr.
fn pkg_no_match(builtin: &str, pm: &str, status: Option<i32>, stderr: &str) -> bool {
match pm {
"apt" => {
(matches!(builtin, "pkg_owner" | "pkg_files") && status == Some(1))
|| stderr.contains("No packages found")
}
"pacman" => {
status == Some(1)
&& ((builtin == "pkg_search" && stderr.trim().is_empty())
|| stderr.contains("was not found")
|| stderr.contains("No package owns"))
}
"dnf" | "yum" => {
stderr.contains("No matching Packages") || stderr.contains("No matches found")
}
"brew" => stderr.contains("No available formula") || stderr.contains("No available cask"),
_ => false,
}
}
/// The error for a package query that exited nonzero. The documented no-match
/// is E_NOT_FOUND naming what was asked for; any other exit is E_TOOL_FAILED
/// with the exit code and the tool's stderr.
fn pkg_query_failure(
builtin: &str,
kind: &str,
name: &str,
pm: &str,
program: &str,
status: Option<i32>,
stderr: &str,
) -> anyhow::Error {
if pkg_no_match(builtin, pm, status, stderr) {
crate::safety::not_found(builtin, kind, name)
} else {
crate::safety::tool_failed(builtin, program, status, stderr)
}
}
fn bi_pkg_search(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let query = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"pkg_search",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
crate::safety::reject_option_like("pkg_search", std::slice::from_ref(&query))?;
let pm = detect_package_manager();
let output = match pm {
"apt" => {
let mut cmd = crate::safety::child_command("apt-cache");
cmd.args(["search", &query]);
bounded_cmd_output("pkg_search", "apt-cache", cmd, TOOL_DEADLINE_SECS)?
}
"dnf" | "yum" => {
let mut cmd = crate::safety::child_command(pm);
cmd.args(["search", &query]);
bounded_cmd_output("pkg_search", pm, cmd, TOOL_DEADLINE_SECS)?
}
"pacman" => {
let mut cmd = crate::safety::child_command("pacman");
cmd.args(["-Ss", &query]);
bounded_cmd_output("pkg_search", "pacman", cmd, TOOL_DEADLINE_SECS)?
}
"brew" => {
let mut cmd = crate::safety::child_command("brew");
cmd.args(["search", &query]);
bounded_cmd_output("pkg_search", "brew", cmd, TOOL_DEADLINE_SECS)?
}
"winget" => {
// `winget search` queries the online sources. apt-cache reads its local index.
crate::safety::check_egress_unknown("pkg_search", PKG_EGRESS_REASON)?;
let mut cmd = crate::safety::child_command("winget");
cmd.args(["search", &query]);
bounded_cmd_output("pkg_search", "winget", cmd, TOOL_DEADLINE_SECS)?
}
other => return Err(pkg_unsupported("pkg_search", other)),
};
let program = match pm {
"apt" => "apt-cache",
"dnf" | "yum" => pm,
"pacman" => "pacman",
"brew" => "brew",
"winget" => "winget",
other => other,
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
// A query that matched nothing is an empty list, not a failure.
if !pkg_no_match("pkg_search", pm, output.status.code(), &stderr) {
return Err(crate::safety::tool_failed(
"pkg_search",
program,
output.status.code(),
&stderr,
));
}
}
let text = String::from_utf8_lossy(&output.stdout);
let results: Vec<Value> = match pm {
"apt" => {
// apt-cache search: "name - description"
text.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| {
let parts: Vec<&str> = l.splitn(2, " - ").collect();
let mut rec = std::collections::BTreeMap::new();
rec.insert("name".to_string(), Value::Str(parts[0].trim().to_string()));
if parts.len() >= 2 {
rec.insert(
"description".to_string(),
Value::Str(parts[1].trim().to_string()),
);
}
Some(Value::Record(rec))
})
.collect()
}
"brew" => {
// brew search: one name per line
text.lines()
.filter(|l| !l.trim().is_empty() && !l.starts_with("==>"))
.map(|l| {
let mut rec = std::collections::BTreeMap::new();
rec.insert("name".to_string(), Value::Str(l.trim().to_string()));
Value::Record(rec)
})
.collect()
}
_ => {
// Generic: parse as whitespace-separated
text.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| {
let mut rec = std::collections::BTreeMap::new();
rec.insert("name".to_string(), Value::Str(l.trim().to_string()));
Value::Record(rec)
})
.collect()
}
};
Ok(Value::Array(results))
}
fn bi_pkg_info(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let package = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"pkg_info",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
crate::safety::reject_option_like("pkg_info", std::slice::from_ref(&package))?;
let pm = detect_package_manager();
let output = match pm {
"apt" => {
let mut cmd = crate::safety::child_command("apt-cache");
cmd.args(["show", &package]);
bounded_cmd_output("pkg_info", "apt-cache", cmd, TOOL_DEADLINE_SECS)?
}
"dnf" | "yum" => {
let mut cmd = crate::safety::child_command(pm);
cmd.args(["info", &package]);
bounded_cmd_output("pkg_info", pm, cmd, TOOL_DEADLINE_SECS)?
}
"pacman" => {
let mut cmd = crate::safety::child_command("pacman");
cmd.args(["-Qi", &package]);
bounded_cmd_output("pkg_info", "pacman", cmd, TOOL_DEADLINE_SECS)?
}
"brew" => {
let mut cmd = crate::safety::child_command("brew");
cmd.args(["info", &package]);
bounded_cmd_output("pkg_info", "brew", cmd, TOOL_DEADLINE_SECS)?
}
"winget" => {
// `winget show` reads the manifest from the online sources.
crate::safety::check_egress_unknown("pkg_info", PKG_EGRESS_REASON)?;
let mut cmd = crate::safety::child_command("winget");
cmd.args(["show", &package]);
bounded_cmd_output("pkg_info", "winget", cmd, TOOL_DEADLINE_SECS)?
}
other => return Err(pkg_unsupported("pkg_info", other)),
};
let program = match pm {
"apt" => "apt-cache",
"dnf" | "yum" => pm,
"pacman" => "pacman",
"brew" => "brew",
"winget" => "winget",
other => other,
};
if !output.status.success() {
return Err(pkg_query_failure(
"pkg_info",
"package",
&package,
pm,
program,
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
// Parse key-value "Field: Value" lines into a Record (works for apt, dnf, pacman, brew, winget)
let text = String::from_utf8_lossy(&output.stdout);
let mut rec = std::collections::BTreeMap::new();
for line in text.lines() {
if let Some(colon_pos) = line.find(':') {
let key = line[..colon_pos].trim();
let value = line[colon_pos + 1..].trim();
if !key.is_empty() && !key.contains(' ') || key.contains("Build Date") {
rec.insert(
key.to_lowercase().replace(' ', "_"),
Value::Str(value.to_string()),
);
}
}
}
if rec.is_empty() {
Ok(Value::Str(text.to_string()))
} else {
Ok(Value::Record(rec))
}
}
#[path = "pkg_admin.rs"]
mod pkg_admin;
fn bi_pkg_install(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
pkg_admin::install(args, input)
}
fn bi_pkg_remove(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
pkg_admin::remove(args, input)
}
fn bi_pkg_update(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
pkg_admin::update(args, input)
}
fn bi_pkg_upgrade(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
pkg_admin::upgrade(args, input)
}
fn bi_pkg_clean(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
pkg_admin::clean(args, input)
}
fn bi_pkg_sources(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
#[cfg(target_os = "linux")]
{
// No sources.list is a system without apt ([] below). One that exists
// but cannot be read used to be the same empty list; it is an error.
let sources = match std::fs::read_to_string("/etc/apt/sources.list") {
Ok(text) => Some(text),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
return Err(crate::safety::fs_error(
"pkg_sources",
"/etc/apt/sources.list",
&e,
))
}
};
if let Some(sources) = sources {
let entries: Vec<Value> = sources
.lines()
.filter(|l| {
let t = l.trim();
!t.is_empty() && !t.starts_with('#')
})
.map(|line| {
let mut rec = std::collections::BTreeMap::new();
let parts: Vec<&str> = line.split_whitespace().collect();
if let Some(typ) = parts.first() {
rec.insert("type".to_string(), Value::Str(typ.to_string()));
}
if let Some(uri) = parts.get(1) {
rec.insert("uri".to_string(), Value::Str(uri.to_string()));
}
if let Some(dist) = parts.get(2) {
rec.insert("distribution".to_string(), Value::Str(dist.to_string()));
}
if parts.len() > 3 {
let components: Vec<Value> = parts[3..]
.iter()
.map(|c| Value::Str(c.to_string()))
.collect();
rec.insert("components".to_string(), Value::Array(components));
}
rec.insert("raw".to_string(), Value::Str(line.to_string()));
Value::Record(rec)
})
.collect();
return Ok(Value::Array(entries));
}
}
Ok(Value::Array(vec![]))
}
fn bi_pkg_add_source(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
pkg_admin::add_source(args, input)
}
fn bi_pkg_files(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let package = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"pkg_files",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
crate::safety::reject_option_like("pkg_files", std::slice::from_ref(&package))?;
let pm = detect_package_manager();
let output = match pm {
"apt" => {
let mut cmd = crate::safety::child_command("dpkg");
cmd.args(["-L", &package]);
bounded_cmd_output("pkg_files", "dpkg", cmd, TOOL_DEADLINE_SECS)?
}
"pacman" => {
let mut cmd = crate::safety::child_command("pacman");
cmd.args(["-Ql", &package]);
bounded_cmd_output("pkg_files", "pacman", cmd, TOOL_DEADLINE_SECS)?
}
other => return Err(pkg_unsupported("pkg_files", other)),
};
let program = match pm {
"apt" => "dpkg",
"pacman" => "pacman",
other => other,
};
if !output.status.success() {
return Err(pkg_query_failure(
"pkg_files",
"package",
&package,
pm,
program,
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
let text = String::from_utf8_lossy(&output.stdout);
let files: Vec<Value> = text
.lines()
// dpkg -L lists the root as "/."; it is not a file of the package.
.filter(|l| !l.trim().is_empty() && l.trim() != "/.")
.map(|l| {
// pacman -Ql prints "package /path"; dpkg -L prints the path alone,
// and splitting that at a space cut "/usr/share/doc/a b" to "b".
match l.split_once(' ') {
Some((_pkg, path)) if pm == "pacman" => Value::Str(path.to_string()),
_ => Value::Str(l.trim().to_string()),
}
})
.collect();
Ok(Value::Array(files))
}
fn bi_pkg_owner(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let file = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"pkg_owner",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
crate::safety::reject_option_like("pkg_owner", std::slice::from_ref(&file))?;
let pm = detect_package_manager();
let output = match pm {
"apt" => {
let mut cmd = crate::safety::child_command("dpkg");
cmd.args(["-S", &file]);
bounded_cmd_output("pkg_owner", "dpkg", cmd, TOOL_DEADLINE_SECS)?
}
"pacman" => {
let mut cmd = crate::safety::child_command("pacman");
cmd.args(["-Qo", &file]);
bounded_cmd_output("pkg_owner", "pacman", cmd, TOOL_DEADLINE_SECS)?
}
other => return Err(pkg_unsupported("pkg_owner", other)),
};
let program = match pm {
"apt" => "dpkg",
"pacman" => "pacman",
other => other,
};
if !output.status.success() {
return Err(pkg_query_failure(
"pkg_owner",
"file",
&file,
pm,
program,
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
let mut rec = std::collections::BTreeMap::new();
// dpkg -S format: "package: /path/to/file"
// pacman -Qo format: "/path/to/file is owned by package version"
if pm == "apt" {
if let Some((pkg, path)) = text.split_once(": ") {
rec.insert("package".to_string(), Value::Str(pkg.to_string()));
rec.insert("file".to_string(), Value::Str(path.to_string()));
} else {
rec.insert("package".to_string(), Value::Str(text));
rec.insert("file".to_string(), Value::Str(file));
}
} else if pm == "pacman" {
// "/path/to/file is owned by package version"
if let Some(pos) = text.find(" is owned by ") {
let owned_by = &text[pos + 13..];
let parts: Vec<&str> = owned_by.split_whitespace().collect();
rec.insert("file".to_string(), Value::Str(text[..pos].to_string()));
rec.insert(
"package".to_string(),
Value::Str(parts.first().unwrap_or(&"").to_string()),
);
if let Some(ver) = parts.get(1) {
rec.insert("version".to_string(), Value::Str(ver.to_string()));
}
} else {
rec.insert("package".to_string(), Value::Str(text));
rec.insert("file".to_string(), Value::Str(file));
}
} else {
rec.insert("package".to_string(), Value::Str(text));
rec.insert("file".to_string(), Value::Str(file));
}
Ok(Value::Record(rec))
}
fn bi_pkg_verify(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let package = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"pkg_verify",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
crate::safety::reject_option_like("pkg_verify", std::slice::from_ref(&package))?;
let pm = detect_package_manager();
let output = match pm {
"apt" => {
let mut cmd = crate::safety::child_command("dpkg");
cmd.args(["-V", &package]);
// Verifying every installed file of a package can be slow.
bounded_cmd_output("pkg_verify", "dpkg", cmd, TOOL_LONG_DEADLINE_SECS)?
}
other => return Err(pkg_unsupported("pkg_verify", other)),
};
// dpkg -V prints one line per file that fails verification.
Ok(Value::Bool(
output.status.success() && String::from_utf8_lossy(&output.stdout).trim().is_empty(),
))
}
/// `apt-cache depends` (" Depends: libc6", " |Depends: a" for an
/// alternative) or `pacman -Si` ("Depends On : a b>=1") as records.
fn parse_pkg_deps(pm: &str, text: &str) -> Value {
let mut out = Vec::new();
let mut push = |kind: &str, package: &str, alternative: bool| {
let mut r = BTreeMap::new();
r.insert("kind".to_string(), Value::Str(kind.to_string()));
r.insert("package".to_string(), Value::Str(package.to_string()));
r.insert("alternative".to_string(), Value::Bool(alternative));
out.push(Value::Record(r));
};
if pm == "pacman" {
for line in text.lines() {
let Some((key, val)) = line.split_once(':') else {
continue;
};
let kind = match key.trim() {
"Depends On" => "Depends",
"Optional Deps" => "Optional",
"Make Deps" => "MakeDepends",
_ => continue,
};
if val.trim() == "None" {
continue;
}
for dep in val.split_whitespace() {
push(kind, dep, false);
}
}
} else {
for line in text.lines().filter(|l| l.starts_with(' ')) {
let t = line.trim();
let (alternative, t) = match t.strip_prefix('|') {
Some(rest) => (true, rest),
None => (false, t),
};
if let Some((kind, pkg)) = t.split_once(':') {
push(kind.trim(), pkg.trim(), alternative);
}
}
}
Value::Array(out)
}
/// `apt-cache rdepends` (names under "Reverse Depends:", some repeated per
/// architecture as "name:arm64") or `pacman -Sii` ("Required By : a b") as a
/// de-duplicated list of package names.
fn parse_pkg_rdeps(pm: &str, package: &str, text: &str) -> Value {
let mut names = std::collections::BTreeSet::new();
if pm == "pacman" {
for line in text.lines() {
if let Some((key, val)) = line.split_once(':') {
if key.trim() == "Required By" && val.trim() != "None" {
names.extend(val.split_whitespace().map(str::to_string));
}
}
}
} else {
let mut listing = false;
for line in text.lines() {
if line.trim_end() == "Reverse Depends:" {
listing = true;
} else if listing && line.starts_with(' ') {
let n = line.trim().trim_start_matches('|');
let n = n.split(':').next().unwrap_or(n);
if !n.is_empty() {
names.insert(n.to_string());
}
}
}
}
// apt lists the package's other architectures ("coreutils:arm64") among
// its reverse dependencies; with the suffix gone that is the package.
names.remove(package);
Value::Array(names.into_iter().map(Value::Str).collect())
}
fn bi_pkg_deps(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let package = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"pkg_deps",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
crate::safety::reject_option_like("pkg_deps", std::slice::from_ref(&package))?;
let pm = detect_package_manager();
let output = match pm {
"apt" => {
let mut cmd = crate::safety::child_command("apt-cache");
cmd.args(["depends", &package]);
bounded_cmd_output("pkg_deps", "apt-cache", cmd, TOOL_DEADLINE_SECS)?
}
"pacman" => {
let mut cmd = crate::safety::child_command("pacman");
cmd.args(["-Si", &package]);
bounded_cmd_output("pkg_deps", "pacman", cmd, TOOL_DEADLINE_SECS)?
}
other => return Err(pkg_unsupported("pkg_deps", other)),
};
let program = match pm {
"apt" => "apt-cache",
"pacman" => "pacman",
other => other,
};
if !output.status.success() {
return Err(pkg_query_failure(
"pkg_deps",
"package",
&package,
pm,
program,
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
// This returned the tool's text verbatim, a different format per
// package manager, which is what typed output exists to replace.
Ok(parse_pkg_deps(pm, &String::from_utf8_lossy(&output.stdout)))
}
fn bi_pkg_rdeps(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let package = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"pkg_rdeps",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
crate::safety::reject_option_like("pkg_rdeps", std::slice::from_ref(&package))?;
let pm = detect_package_manager();
let output = match pm {
"apt" => {
let mut cmd = crate::safety::child_command("apt-cache");
cmd.args(["rdepends", &package]);
bounded_cmd_output("pkg_rdeps", "apt-cache", cmd, TOOL_DEADLINE_SECS)?
}
"pacman" => {
let mut cmd = crate::safety::child_command("pacman");
cmd.args(["-Sii", &package]);
bounded_cmd_output("pkg_rdeps", "pacman", cmd, TOOL_DEADLINE_SECS)?
}
other => return Err(pkg_unsupported("pkg_rdeps", other)),
};
let program = match pm {
"apt" => "apt-cache",
"pacman" => "pacman",
other => other,
};
if !output.status.success() {
return Err(pkg_query_failure(
"pkg_rdeps",
"package",
&package,
pm,
program,
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
// This returned the tool's text verbatim, a different format per
// package manager, which is what typed output exists to replace.
Ok(parse_pkg_rdeps(
pm,
&package,
&String::from_utf8_lossy(&output.stdout),
))
}
#[cfg(test)]
mod pkg_exit_status_tests {
use super::*;
#[test]
fn dpkg_exit_one_is_no_match_for_owner_and_files() {
assert!(pkg_no_match(
"pkg_owner",
"apt",
Some(1),
"dpkg-query: no path found matching pattern /nope"
));
assert!(pkg_no_match(
"pkg_files",
"apt",
Some(1),
"dpkg-query: package 'nope' is not installed"
));
// A dpkg fatal error (exit 2), or a killed process, is a failure.
assert!(!pkg_no_match(
"pkg_owner",
"apt",
Some(2),
"dpkg-query: error: bad database"
));
assert!(!pkg_no_match("pkg_owner", "apt", None, ""));
// Exit 1 is not a no-match for the apt-cache queries.
assert!(!pkg_no_match(
"pkg_deps",
"apt",
Some(1),
"E: Unable to locate package x"
));
}
#[test]
fn apt_cache_no_packages_found_is_no_match() {
assert!(pkg_no_match(
"pkg_info",
"apt",
Some(100),
"E: No packages found"
));
assert!(pkg_no_match(
"pkg_rdeps",
"apt",
Some(100),
"E: No packages found"
));
assert!(!pkg_no_match(
"pkg_info",
"apt",
Some(100),
"E: Could not open lock file"
));
}
#[test]
fn pacman_search_with_no_match_is_empty_not_a_failure() {
assert!(pkg_no_match("pkg_search", "pacman", Some(1), ""));
assert!(!pkg_no_match(
"pkg_search",
"pacman",
Some(1),
"error: failed to open database"
));
assert!(!pkg_no_match("pkg_search", "pacman", Some(2), ""));
}
#[test]
fn pacman_queries_report_unknown_packages_and_unowned_files() {
assert!(pkg_no_match(
"pkg_info",
"pacman",
Some(1),
"error: package 'nope' was not found"
));
assert!(pkg_no_match(
"pkg_deps",
"pacman",
Some(1),
"error: package 'nope' was not found"
));
assert!(pkg_no_match(
"pkg_owner",
"pacman",
Some(1),
"error: No package owns /nope"
));
// A lock or database error is a failure, not an answer.
assert!(!pkg_no_match(
"pkg_info",
"pacman",
Some(1),
"error: failed to lock database"
));
}
#[test]
fn other_managers_no_match_messages() {
assert!(pkg_no_match(
"pkg_info",
"brew",
Some(1),
"Error: No available formula with the name \"x\"."
));
assert!(pkg_no_match(
"pkg_info",
"dnf",
Some(1),
"Error: No matching Packages to list"
));
assert!(!pkg_no_match(
"pkg_info",
"winget",
Some(1),
"No package found matching input criteria."
));
}
#[test]
fn a_no_match_is_e_not_found_and_a_failure_is_e_tool_failed() {
let e = pkg_query_failure(
"pkg_info",
"package",
"nope",
"pacman",
"pacman",
Some(1),
"error: package 'nope' was not found",
)
.to_string();
assert!(e.contains("E_NOT_FOUND") && e.contains("nope"), "{e}");
assert!(!e.contains("E_TOOL_FAILED"), "{e}");
let e = pkg_query_failure(
"pkg_owner",
"file",
"/x",
"apt",
"dpkg",
Some(2),
"dpkg-query: error: bad database",
)
.to_string();
assert!(e.contains("E_TOOL_FAILED"), "{e}");
assert!(e.contains("exited 2") && e.contains("bad database"), "{e}");
}
}
/// pkg_changelog(package): apt-get changelog for a package, as text. Only apt is
/// supported. A changelog fetch reaches the network, and its output is bounded.
fn bi_pkg_changelog(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "pkg_changelog";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 1)?;
let package = k8s_need(B, "a package name", &args, 0, pkg_plain)?;
// The package name is not a host, so the dispatcher does not judge it (see
// EGRESS_JUDGED_BY_BODY). The destination is the configured repository, which
// the arguments do not name: fail closed under an allowlist, as pkg_install does.
crate::safety::check_egress_unknown(B, PKG_EGRESS_REASON)?;
let program = match detect_package_manager() {
"apt" => "apt-get",
other => return Err(pkg_unsupported(B, other)),
};
let run = tool_run(
B,
program,
None,
&["changelog", package.as_str()],
Keep::Head(TOOL_MAX_TEXT),
TOOL_DEADLINE_SECS,
)?;
Ok(Value::Str(tool_text(B, run)?))
}
fn bi_pkg_hold(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
pkg_admin::hold(args, input)
}
fn bi_pkg_unhold(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
pkg_admin::unhold(args, input)
}
fn bi_pkg_autoremove(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
pkg_admin::autoremove(args, input)
}
/// The `limit` argument: absent is the default, otherwise an Int from 1 to 1000.
/// Checked on every OS, before the platform refusal, so a bad call is E_BAD_ARG
/// everywhere.
fn pkg_history_limit(args: &[Value], input: &Option<Value>) -> Result<usize> {
linux_diagnostics::no_pipeline_input("pkg_history", input)?;
match args {
[] => Ok(pkg_query::HISTORY_DEFAULT),
[Value::Int(n)] if (1..=pkg_query::HISTORY_MAX as i64).contains(n) => Ok(*n as usize),
[Value::Int(n)] => Err(crate::safety::arg_err(format!(
"pkg_history: limit must be from 1 to {}, got {n}",
pkg_query::HISTORY_MAX
))),
[other] => Err(crate::safety::bad_arg(
"pkg_history",
"an Int limit from 1 to 1000",
other.type_name(),
)),
more => Err(crate::safety::arg_err(format!(
"pkg_history takes at most one argument, got {}",
more.len()
))),
}
}
fn bi_pkg_history(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let limit = pkg_history_limit(&args, &input)?;
pkg_history_read(limit)
}
/// macOS: the installs in /var/log/install.log, read whole up to the file-size
/// limit and parsed by pkg_query::parse_install_log. Only installs are read, so
/// removed is always empty. Each row has the apt row's shape, with end and
/// command null.
#[cfg(target_os = "macos")]
fn pkg_history_read(limit: usize) -> Result<Value> {
let text = read_text_capped("pkg_history", pkg_query::INSTALL_LOG)?;
let rows = pkg_query::parse_install_log(&text, limit);
Ok(Value::Array(
rows.into_iter().map(pkg_query::transaction_value).collect(),
))
}
#[cfg(not(any(target_os = "linux", windows, target_os = "macos")))]
fn pkg_history_read(_limit: usize) -> Result<Value> {
// The apt history log is Linux's, the install log is macOS's and the
// Windows Installer log is Windows'. Nothing is read here.
Err(crate::safety::unimplemented(
"pkg_history",
"package history is read from the apt log on Linux, the install log on macOS and the Windows Installer log on Windows; NOTHING WAS READ",
"ask the package manager directly (brew list)",
))
}
#[cfg(windows)]
fn pkg_history_read(limit: usize) -> Result<Value> {
let script = WIN_PKG_HISTORY_SCRIPT.replace("__LIMIT__", &limit.to_string());
Ok(Value::Array(win_event_rows("pkg_history", &script)?))
}
#[cfg(target_os = "linux")]
fn pkg_history_read(limit: usize) -> Result<Value> {
let text = pkg_query::read_history_tail("pkg_history", pkg_query::HISTORY_LOG)?;
let transactions = pkg_query::parse_history(&text, limit)
.map_err(|e| pkg_query::parse_failure("pkg_history", "apt history log", e))?;
Ok(Value::Array(
transactions
.into_iter()
.map(pkg_query::transaction_value)
.collect(),
))
}
// ============================================================================
// 390-399: Hardware & Device Info
// ============================================================================
fn bi_hw_cpu(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
bi_sys_cpu_info(vec![], None)
}
fn bi_hw_memory(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
bi_sys_mem_info(vec![], None)
}
fn bi_hw_disk(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
bi_fs_df(vec![], None)
}
/// The GPU probe: one PowerShell pipeline. Win32_VideoController.AdapterRAM
/// is a uint32, so it cannot say more than 4 GiB. The driver's 64-bit size is
/// in the display class registry key: each adapter's DriverDesc equals its
/// Name, and HardwareInformation.qwMemorySize holds the size. The output is a
/// JSON array (or one object) of Name, AdapterRAM, DriverVersion and
/// QwMemorySize, which is null when no driver key matches.
#[cfg(target_os = "windows")]
const WIN_GPU_PROBE_SCRIPT: &str = r#"$ErrorActionPreference='SilentlyContinue'; $cls='HKLM:\SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}'; $drv=@(Get-ChildItem -LiteralPath $cls | ForEach-Object { Get-ItemProperty -Path $_.PSPath } | Where-Object { $_.DriverDesc } | ForEach-Object { [pscustomobject]@{ Desc=[string]$_.DriverDesc; Qw=$_.'HardwareInformation.qwMemorySize' } }); $rows=@(Get-CimInstance Win32_VideoController | ForEach-Object { $n=$_.Name; $m=@($drv | Where-Object { $_.Desc -eq $n })[0]; $q=$null; if ($m) { $q=$m.Qw }; if ($q -is [byte[]]) { if ($q.Length -ge 8) { $q=[BitConverter]::ToInt64($q,0) } else { $q=$null } }; [pscustomobject]@{ Name=$n; AdapterRAM=$_.AdapterRAM; DriverVersion=$_.DriverVersion; QwMemorySize=$q } }); ConvertTo-Json -InputObject $rows"#;
/// One display adapter from the probe. `vram_bytes` is the driver's 64-bit
/// size when it is positive, else the 32-bit AdapterRAM.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
#[derive(Debug, Clone, PartialEq)]
struct WinGpu {
name: Option<String>,
vram_bytes: Option<i64>,
driver: Option<String>,
}
/// Parses the probe's JSON (an array, or one object) into adapters. Pure, so
/// it is compiled and tested on every platform.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn win_gpus_from_probe(json: &serde_json::Value) -> Vec<WinGpu> {
let items: Vec<&serde_json::Value> = match json {
serde_json::Value::Array(arr) => arr.iter().collect(),
obj @ serde_json::Value::Object(_) => vec![obj],
_ => vec![],
};
items
.into_iter()
.filter_map(|item| {
let o = item.as_object()?;
let text = |key: &str| o.get(key).and_then(|v| v.as_str()).map(str::to_string);
let driver_size = o
.get("QwMemorySize")
.and_then(|v| v.as_i64())
.filter(|n| *n > 0);
let adapter_ram = o.get("AdapterRAM").and_then(|v| v.as_i64());
Some(WinGpu {
name: text("Name"),
vram_bytes: driver_size.or(adapter_ram),
driver: text("DriverVersion"),
})
})
.collect()
}
/// The hw_gpu record for one adapter: {name, vram, driver}.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn win_gpu_record(g: &WinGpu) -> Value {
let mut rec = BTreeMap::new();
if let Some(name) = &g.name {
rec.insert("name".to_string(), Value::Str(name.clone()));
}
if let Some(bytes) = g.vram_bytes {
rec.insert("vram".to_string(), Value::Int(bytes));
}
if let Some(driver) = &g.driver {
rec.insert("driver".to_string(), Value::Str(driver.clone()));
}
Value::Record(rec)
}
/// Runs the GPU probe. `None` when PowerShell fails or prints no JSON.
#[cfg(target_os = "windows")]
fn win_gpu_probe(builtin: &str) -> Result<Option<Vec<WinGpu>>> {
let mut cmd = crate::safety::child_command("powershell");
cmd.args(["-Command", WIN_GPU_PROBE_SCRIPT]);
let output = bounded_cmd_output(builtin, "powershell", cmd, PROBE_DEADLINE_SECS)?;
if !output.status.success() {
return Ok(None);
}
let text = String::from_utf8_lossy(&output.stdout);
Ok(serde_json::from_str::<serde_json::Value>(text.trim())
.ok()
.map(|json| win_gpus_from_probe(&json)))
}
fn bi_hw_gpu(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
#[cfg(target_os = "windows")]
{
if let Some(gpus) = win_gpu_probe("hw_gpu")? {
return Ok(Value::Array(gpus.iter().map(win_gpu_record).collect()));
}
}
#[cfg(target_os = "linux")]
{
// Use sh -c for proper shell pipeline expansion
let mut cmd = crate::safety::child_command("sh");
cmd.args(["-c", "lspci | grep -iE 'vga|3d|display'"]);
let output = bounded_cmd_output("hw_gpu", "sh", cmd, PROBE_DEADLINE_SECS)?;
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout);
let gpus: Vec<Value> = text
.lines()
.filter_map(|line| {
let mut rec = std::collections::BTreeMap::new();
// Format: "01:00.0 VGA compatible controller: NVIDIA Corporation ..."
if let Some(desc_start) = line.find(": ") {
rec.insert(
"name".to_string(),
Value::Str(line[desc_start + 2..].to_string()),
);
} else {
rec.insert("name".to_string(), Value::Str(line.to_string()));
}
Some(Value::Record(rec))
})
.collect();
return Ok(Value::Array(gpus));
}
}
#[cfg(target_os = "macos")]
{
let mut cmd = crate::safety::child_command("system_profiler");
cmd.args(["SPDisplaysDataType", "-json"]);
// A failing system_profiler, or output that is not the expected JSON,
// used to answer Null ("no GPU"). Both are errors now.
let output = bounded_cmd_output("hw_gpu", "system_profiler", cmd, PROBE_DEADLINE_SECS)?;
let status = output.status.code();
let json_str = probe_stdout("hw_gpu", "system_profiler", output)?;
let json: serde_json::Value = serde_json::from_str(&json_str).map_err(|e| {
crate::safety::tool_failed(
"hw_gpu",
"system_profiler SPDisplaysDataType -json",
status,
&format!("its output was not JSON: {e}"),
)
})?;
let Some(displays) = json.get("SPDisplaysDataType").and_then(|v| v.as_array()) else {
return Err(crate::safety::tool_failed(
"hw_gpu",
"system_profiler SPDisplaysDataType -json",
status,
"its output had no SPDisplaysDataType list",
));
};
let gpus: Vec<Value> = displays
.iter()
.filter_map(|d| {
let o = d.as_object()?;
let mut rec = std::collections::BTreeMap::new();
if let Some(v) = o.get("sppci_model") {
rec.insert("name".to_string(), json_to_value(v.clone()));
}
if let Some(v) = o.get("spdisplays_vram") {
rec.insert("vram".to_string(), json_to_value(v.clone()));
}
if let Some(v) = o.get("sppci_vendor") {
rec.insert("vendor".to_string(), json_to_value(v.clone()));
}
Some(Value::Record(rec))
})
.collect();
return Ok(Value::Array(gpus));
}
Ok(Value::Null)
}
fn bi_hw_network(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
bi_net_interfaces(vec![], None)
}
fn bi_hw_usb(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
#[cfg(target_os = "windows")]
{
let mut cmd = crate::safety::child_command("powershell");
cmd.args([
"-Command",
"Get-PnpDevice -Class USB | Select-Object FriendlyName,Status | ConvertTo-Json",
]);
let output = bounded_cmd_output("hw_usb", "powershell", cmd, PROBE_DEADLINE_SECS)?;
if output.status.success() {
let json_str = String::from_utf8_lossy(&output.stdout);
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&json_str) {
let items = match &json {
serde_json::Value::Array(arr) => arr.clone(),
obj @ serde_json::Value::Object(_) => vec![obj.clone()],
_ => vec![],
};
let devices: Vec<Value> = items
.iter()
.filter_map(|obj| {
let o = obj.as_object()?;
let mut rec = std::collections::BTreeMap::new();
if let Some(v) = o.get("FriendlyName") {
rec.insert("name".to_string(), json_to_value(v.clone()));
}
if let Some(v) = o.get("Status") {
rec.insert("status".to_string(), json_to_value(v.clone()));
}
Some(Value::Record(rec))
})
.collect();
return Ok(Value::Array(devices));
}
}
}
#[cfg(target_os = "linux")]
{
let cmd = crate::safety::child_command("lsusb");
let output = bounded_cmd_output("hw_usb", "lsusb", cmd, PROBE_DEADLINE_SECS)?;
if output.status.success() {
let devices: Vec<Value> = String::from_utf8_lossy(&output.stdout)
.lines()
.map(|line| {
let mut rec = std::collections::BTreeMap::new();
rec.insert("name".to_string(), Value::Str(line.to_string()));
Value::Record(rec)
})
.collect();
return Ok(Value::Array(devices));
}
}
#[cfg(target_os = "macos")]
{
let mut cmd = crate::safety::child_command("system_profiler");
cmd.args(["SPUSBDataType", "-json"]);
let output = bounded_cmd_output("hw_usb", "system_profiler", cmd, PROBE_DEADLINE_SECS)?;
if output.status.success() {
let json_str = String::from_utf8_lossy(&output.stdout);
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&json_str) {
if let Some(items) = json.get("SPUSBDataType").and_then(|v| v.as_array()) {
let devices: Vec<Value> = items
.iter()
.filter_map(|item| {
let o = item.as_object()?;
let mut rec = std::collections::BTreeMap::new();
if let Some(v) = o.get("_name") {
rec.insert("name".to_string(), json_to_value(v.clone()));
}
if let Some(v) = o.get("manufacturer") {
rec.insert("vendor".to_string(), json_to_value(v.clone()));
}
Some(Value::Record(rec))
})
.collect();
return Ok(Value::Array(devices));
}
}
}
}
// A failed query answered null.
Err(crate::safety::tool_failed(
"lsusb",
"the device query",
None,
"it failed or printed nothing usable",
))
}
fn bi_hw_pci(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
#[cfg(target_os = "linux")]
{
let cmd = crate::safety::child_command("lspci");
let output = bounded_cmd_output("hw_pci", "lspci", cmd, PROBE_DEADLINE_SECS)?;
if output.status.success() {
let devices: Vec<Value> = String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| {
let mut rec = std::collections::BTreeMap::new();
if let Some(pos) = line.find(' ') {
rec.insert("address".to_string(), Value::Str(line[..pos].to_string()));
rec.insert("name".to_string(), Value::Str(line[pos + 1..].to_string()));
} else {
rec.insert("name".to_string(), Value::Str(line.to_string()));
}
Some(Value::Record(rec))
})
.collect();
return Ok(Value::Array(devices));
}
}
#[cfg(target_os = "windows")]
{
let mut cmd = crate::safety::child_command("powershell");
cmd.args([
"-Command",
"Get-PnpDevice | Select-Object FriendlyName,Class | ConvertTo-Json",
]);
let output = bounded_cmd_output("hw_pci", "powershell", cmd, PROBE_DEADLINE_SECS)?;
if output.status.success() {
let json_str = String::from_utf8_lossy(&output.stdout);
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&json_str) {
let items = match &json {
serde_json::Value::Array(arr) => arr.clone(),
obj @ serde_json::Value::Object(_) => vec![obj.clone()],
_ => vec![],
};
let devices: Vec<Value> = items
.iter()
.filter_map(|obj| {
let o = obj.as_object()?;
let mut rec = std::collections::BTreeMap::new();
if let Some(v) = o.get("FriendlyName") {
rec.insert("name".to_string(), json_to_value(v.clone()));
}
if let Some(v) = o.get("Class") {
rec.insert("class".to_string(), json_to_value(v.clone()));
}
Some(Value::Record(rec))
})
.collect();
return Ok(Value::Array(devices));
}
}
}
#[cfg(target_os = "macos")]
{
let mut cmd = crate::safety::child_command("system_profiler");
cmd.args(["SPPCIDataType", "-json"]);
let output = bounded_cmd_output("hw_pci", "system_profiler", cmd, PROBE_DEADLINE_SECS)?;
if output.status.success() {
let json_str = String::from_utf8_lossy(&output.stdout);
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&json_str) {
if let Some(items) = json.get("SPPCIDataType").and_then(|v| v.as_array()) {
let devices: Vec<Value> = items
.iter()
.filter_map(|item| {
let o = item.as_object()?;
let mut rec = std::collections::BTreeMap::new();
if let Some(v) = o.get("sppci_name") {
rec.insert("name".to_string(), json_to_value(v.clone()));
}
if let Some(v) = o.get("sppci_vendor") {
rec.insert("vendor".to_string(), json_to_value(v.clone()));
}
Some(Value::Record(rec))
})
.collect();
return Ok(Value::Array(devices));
}
}
}
}
// A failed query answered null.
Err(crate::safety::tool_failed(
"lspci",
"the device query",
None,
"it failed or printed nothing usable",
))
}
fn bi_hw_audio(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
if !args.is_empty() {
return Err(crate::safety::bad_arg(
"hw_audio",
"no arguments",
&format!("{} argument(s)", args.len()),
));
}
#[cfg(target_os = "windows")]
{
let mut cmd = crate::safety::child_command("powershell");
cmd.args([
"-Command",
"Get-CimInstance Win32_SoundDevice | Select-Object Name,Status | ConvertTo-Json",
]);
let output = bounded_cmd_output("hw_audio", "powershell", cmd, PROBE_DEADLINE_SECS)?;
if output.status.success() {
let json_str = String::from_utf8_lossy(&output.stdout);
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&json_str) {
let items = match &json {
serde_json::Value::Array(arr) => arr.clone(),
obj @ serde_json::Value::Object(_) => vec![obj.clone()],
_ => vec![],
};
let devices: Vec<Value> = items
.iter()
.filter_map(|obj| {
let o = obj.as_object()?;
let mut rec = std::collections::BTreeMap::new();
if let Some(v) = o.get("Name") {
rec.insert("name".to_string(), json_to_value(v.clone()));
}
if let Some(v) = o.get("Status") {
rec.insert("status".to_string(), json_to_value(v.clone()));
}
Some(Value::Record(rec))
})
.collect();
return Ok(Value::Array(devices));
}
}
}
#[cfg(target_os = "linux")]
{
let mut cmd = crate::safety::child_command("aplay");
cmd.args(["-l"]);
let output = bounded_cmd_output("hw_audio", "aplay", cmd, PROBE_DEADLINE_SECS)?;
if output.status.success() {
let devices: Vec<Value> = String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|line| line.starts_with("card "))
.map(|line| {
let mut rec = std::collections::BTreeMap::new();
rec.insert("name".to_string(), Value::Str(line.to_string()));
Value::Record(rec)
})
.collect();
return Ok(Value::Array(devices));
}
}
#[cfg(target_os = "macos")]
{
let mut cmd = crate::safety::child_command("system_profiler");
cmd.args(["SPAudioDataType", "-json"]);
let output = bounded_cmd_output("hw_audio", "system_profiler", cmd, PROBE_DEADLINE_SECS)?;
if output.status.success() {
let json_str = String::from_utf8_lossy(&output.stdout);
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&json_str) {
if let Some(items) = json.get("SPAudioDataType").and_then(|v| v.as_array()) {
let devices: Vec<Value> = items
.iter()
.filter_map(|item| {
let o = item.as_object()?;
let mut rec = std::collections::BTreeMap::new();
if let Some(v) = o.get("_name") {
rec.insert("name".to_string(), json_to_value(v.clone()));
}
if let Some(v) = o.get("coreaudio_default_audio_output_device") {
rec.insert("default_output".to_string(), json_to_value(v.clone()));
}
Some(Value::Record(rec))
})
.collect();
return Ok(Value::Array(devices));
}
}
}
}
// Reached only when the query above did not yield a list. That used to be
// Ok(Null), which reads as "no audio devices" and hid a failed query.
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
{
Err(crate::safety::tool_failed(
"hw_audio",
"the audio device query",
None,
"it failed or printed nothing usable",
))
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
Err(crate::safety::unimplemented(
"hw_audio",
"audio device listing is not implemented on this operating system",
"list audio devices with the platform's own tool",
))
}
}
/// The charge percentage in a sysfs `capacity` file. Anything that is not an
/// integer is refused as an unreadable file, never read as 0%.
#[cfg(any(target_os = "linux", test))]
fn battery_capacity_from(path: &str, text: &str) -> Result<i64> {
text.trim().parse::<i64>().map_err(|_| {
let cause = std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("`{}` is not a percentage", text.trim()),
);
crate::safety::fs_error("hw_battery", path, &cause)
})
}
/// Maps Win32_Battery.BatteryStatus (Microsoft's documented codes) to the
/// status string hw_battery reports on Windows. 2 is "ac": on AC power and not
/// charging, which is what a UPS reports. The Linux branch reads sysfs and
/// reports "not charging" for the same state.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn windows_battery_status(code: i64) -> &'static str {
match code {
1 => "discharging",
2 => "ac",
3 => "full",
4 => "low",
5 => "critical",
6..=9 => "charging",
11 => "partially_charged",
_ => "unknown",
}
}
/// The hw_battery record from the Win32_Battery probe JSON (an object, or the
/// first element of an array). `name` is Win32_Battery's Name when present, so
/// a UPS can be told from a laptop battery.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn windows_battery_from_probe(json: &serde_json::Value) -> Option<Value> {
let obj = match json {
serde_json::Value::Object(o) => o,
serde_json::Value::Array(arr) => arr.first()?.as_object()?,
_ => return None,
};
let mut rec = BTreeMap::new();
if let Some(v) = obj.get("EstimatedChargeRemaining").and_then(|v| v.as_i64()) {
rec.insert("capacity".to_string(), Value::Int(v));
}
let status = obj
.get("BatteryStatus")
.and_then(|v| v.as_i64())
.map_or("unknown", windows_battery_status);
rec.insert("status".to_string(), Value::Str(status.to_string()));
if let Some(name) = obj
.get("Name")
.and_then(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
{
rec.insert("name".to_string(), Value::Str(name.to_string()));
}
Some(Value::Record(rec))
}
fn bi_hw_battery(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// Returns Record {capacity: Int (%), status: String, name: String (Windows)}
#[cfg(target_os = "windows")]
{
let mut cmd = crate::safety::child_command("powershell");
cmd
.args(["-Command", "Get-CimInstance Win32_Battery | Select-Object Name,EstimatedChargeRemaining,BatteryStatus | ConvertTo-Json"]);
let output = bounded_cmd_output("hw_battery", "powershell", cmd, PROBE_DEADLINE_SECS)?;
if output.status.success() {
let json_str = String::from_utf8_lossy(&output.stdout);
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&json_str) {
if let Some(rec) = windows_battery_from_probe(&json) {
return Ok(rec);
}
}
}
}
#[cfg(target_os = "linux")]
{
let capacity_path = "/sys/class/power_supply/BAT0/capacity";
let status_path = "/sys/class/power_supply/BAT0/status";
let mut rec = std::collections::BTreeMap::new();
// No BAT0 is a host without that battery. A capacity that is not a
// number used to be read as 0%, which is a real and alarming answer.
match std::fs::read_to_string(capacity_path) {
Ok(text) => {
rec.insert(
"capacity".to_string(),
Value::Int(battery_capacity_from(capacity_path, &text)?),
);
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(crate::safety::fs_error("hw_battery", capacity_path, &e)),
}
if let Ok(status) = std::fs::read_to_string(status_path) {
rec.insert(
"status".to_string(),
Value::Str(status.trim().to_lowercase()),
);
}
if !rec.is_empty() {
return Ok(Value::Record(rec));
}
}
#[cfg(target_os = "macos")]
{
let mut cmd = crate::safety::child_command("pmset");
cmd.args(["-g", "batt"]);
let output = bounded_cmd_output("hw_battery", "pmset", cmd, PROBE_DEADLINE_SECS)?;
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout);
let mut rec = std::collections::BTreeMap::new();
// Format: "... XX%; charging/discharging/charged; ..."
for line in text.lines() {
if let Some(pct_pos) = line.find('%') {
// Walk backwards from % to find the number
let before = &line[..pct_pos];
let num_str: String = before
.chars()
.rev()
.take_while(|c| c.is_ascii_digit())
.collect::<String>()
.chars()
.rev()
.collect();
if let Ok(cap) = num_str.parse::<i64>() {
rec.insert("capacity".to_string(), Value::Int(cap));
}
// Status is after %; like "charging" or "discharging"
let after = &line[pct_pos + 1..];
for part in after.split(';') {
let part = part.trim().to_lowercase();
if part.contains("charging")
|| part.contains("charged")
|| part.contains("discharging")
{
let status = if part.contains("discharging") {
"discharging"
} else if part.contains("charged") {
"full"
} else {
"charging"
};
rec.insert("status".to_string(), Value::Str(status.to_string()));
break;
}
}
}
}
if !rec.is_empty() {
return Ok(Value::Record(rec));
}
}
}
Ok(Value::Null)
}
fn bi_hw_sensors(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// Returns consistent Array of Records {name: String, temp_c: Float}
#[cfg(target_os = "windows")]
{
let mut cmd = crate::safety::child_command("powershell");
cmd
.args(["-Command", "Get-CimInstance MSAcpi_ThermalZoneTemperature -Namespace root/wmi 2>$null | Select-Object InstanceName,CurrentTemperature | ConvertTo-Json"]);
let output = bounded_cmd_output("hw_sensors", "powershell", cmd, PROBE_DEADLINE_SECS)?;
if output.status.success() {
let json_str = String::from_utf8_lossy(&output.stdout);
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&json_str) {
let items = match &json {
serde_json::Value::Array(arr) => arr.clone(),
obj @ serde_json::Value::Object(_) => vec![obj.clone()],
_ => vec![],
};
let sensors: Vec<Value> = items
.iter()
.filter_map(|obj| {
let o = obj.as_object()?;
let mut rec = std::collections::BTreeMap::new();
if let Some(v) = o.get("InstanceName") {
rec.insert("name".to_string(), json_to_value(v.clone()));
}
// WMI returns tenths of kelvin
if let Some(v) = o.get("CurrentTemperature").and_then(|v| v.as_f64()) {
rec.insert("temp_c".to_string(), Value::Float((v / 10.0) - 273.15));
}
Some(Value::Record(rec))
})
.collect();
return Ok(Value::Array(sensors));
}
}
}
#[cfg(target_os = "linux")]
{
// Try /sys/class/thermal first for structured data
let thermal_dir = std::path::Path::new("/sys/class/thermal");
if thermal_dir.exists() {
let mut sensors = Vec::new();
if let Ok(entries) = std::fs::read_dir(thermal_dir) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with("thermal_zone") {
let temp_path = entry.path().join("temp");
let type_path = entry.path().join("type");
let mut rec = std::collections::BTreeMap::new();
let zone_name = std::fs::read_to_string(&type_path)
.unwrap_or_else(|_| name.clone())
.trim()
.to_string();
rec.insert("name".to_string(), Value::Str(zone_name));
if let Ok(temp_str) = std::fs::read_to_string(&temp_path) {
if let Ok(millideg) = temp_str.trim().parse::<f64>() {
rec.insert("temp_c".to_string(), Value::Float(millideg / 1000.0));
}
}
sensors.push(Value::Record(rec));
}
}
}
if !sensors.is_empty() {
return Ok(Value::Array(sensors));
}
}
}
#[cfg(target_os = "macos")]
{
// macOS doesn't expose thermal sensors easily without IOKit bindings;
// use powermetrics or osx-cpu-temp if available, fallback to empty
let mut sensors_cmd = crate::safety::child_command("sh");
sensors_cmd.args(["-c", "which osx-cpu-temp && osx-cpu-temp"]);
let output = bounded_cmd_output("hw_sensors", "sh", sensors_cmd, PROBE_DEADLINE_SECS);
if let Ok(out) = output {
if out.status.success() {
let text = String::from_utf8_lossy(&out.stdout);
let mut rec = std::collections::BTreeMap::new();
rec.insert("name".to_string(), Value::Str("CPU".to_string()));
// Parse "65.0°C" style output
let temp_str: String = text
.chars()
.take_while(|c| c.is_ascii_digit() || *c == '.')
.collect();
if let Ok(temp) = temp_str.parse::<f64>() {
rec.insert("temp_c".to_string(), Value::Float(temp));
}
return Ok(Value::Array(vec![Value::Record(rec)]));
}
}
}
Ok(Value::Null)
}
// ============================================================================
// 400-429: GUI Automation
// ============================================================================
/// Largest window title, prompt, message or path a GUI builtin takes.
const GUI_FIELD_MAX_BYTES: usize = 4096;
/// Deadline for a desktop dialog (zenity, osascript). A person answers it, so
/// it waits far longer than a probe, but a dialog nobody dismisses must still
/// end: the child is killed at this deadline and the call is a budget error.
const GUI_DIALOG_DEADLINE_SECS: u64 = 300;
/// Largest text `gui_type_text` types in one call.
const GUI_TYPE_MAX_BYTES: usize = 10_000;
/// Largest clipboard text one call reads or writes.
const CLIPBOARD_MAX_BYTES: usize = 8 * 1024 * 1024;
/// Longest key spec `gui_key_press` and `gui_key_combo` accept.
const GUI_KEY_MAX_BYTES: usize = 64;
/// Bound on a screen coordinate, in pixels, in either direction.
const GUI_COORD_LIMIT: i64 = 100_000;
/// The keys a key call names by word. A single letter or digit is also accepted.
const GUI_NAMED_KEYS: &[&str] = &[
"enter",
"tab",
"esc",
"space",
"backspace",
"delete",
"home",
"end",
"pageup",
"pagedown",
"up",
"down",
"left",
"right",
"f1",
"f2",
"f3",
"f4",
"f5",
"f6",
"f7",
"f8",
"f9",
"f10",
"f11",
"f12",
];
/// A String argument of a GUI or clipboard builtin: at most `max` bytes, free
/// of control characters other than newline and tab, and non-empty unless
/// `allow_empty`. Anything else is refused before a tool runs.
fn gui_text(
name: &str,
what: &str,
value: &Value,
max: usize,
allow_empty: bool,
) -> Result<String> {
let s = match value {
Value::Str(s) => s,
other => {
return Err(crate::safety::bad_arg(
name,
&format!("{what}: String"),
other.type_name(),
))
}
};
if s.len() > max {
return Err(crate::safety::bad_arg(
name,
&format!("{what} of at most {max} bytes"),
&format!("{} bytes", s.len()),
));
}
if s.is_empty() && !allow_empty {
return Err(crate::safety::bad_arg(
name,
&format!("{what}: a non-empty String"),
"an empty String",
));
}
if s.chars().any(|c| c.is_control() && c != '\n' && c != '\t') {
return Err(crate::safety::bad_arg(
name,
&format!("{what} without control characters"),
"a control character",
));
}
Ok(s.clone())
}
/// The argument at `index`, which the signature makes required. A caller that
/// reaches here without it is refused, not allowed to panic.
fn gui_required<'a>(name: &str, args: &'a [Value], index: usize, what: &str) -> Result<&'a Value> {
args.get(index)
.ok_or_else(|| crate::safety::bad_arg(name, what, "nothing"))
}
/// Refuses pipeline input for a GUI or clipboard builtin that takes none.
fn refuse_piped_input(name: &str, input: &Option<Value>) -> Result<()> {
if input.is_some() {
return Err(crate::safety::bad_arg(
name,
"no pipeline input",
"piped input",
));
}
Ok(())
}
/// A screen coordinate or pointer position: an Int within `GUI_COORD_LIMIT`.
fn gui_coord(name: &str, what: &str, value: &Value) -> Result<i64> {
match value {
Value::Int(n) if (-GUI_COORD_LIMIT..=GUI_COORD_LIMIT).contains(n) => Ok(*n),
Value::Int(n) => Err(crate::safety::bad_arg(
name,
&format!("{what} in -{GUI_COORD_LIMIT}..={GUI_COORD_LIMIT}"),
&n.to_string(),
)),
other => Err(crate::safety::bad_arg(
name,
&format!("{what}: Int"),
other.type_name(),
)),
}
}
/// A window size in pixels: an Int from 1 to `GUI_COORD_LIMIT`.
fn gui_size(name: &str, what: &str, value: &Value) -> Result<i64> {
match value {
Value::Int(n) if (1..=GUI_COORD_LIMIT).contains(n) => Ok(*n),
Value::Int(n) => Err(crate::safety::bad_arg(
name,
&format!("{what} in 1..={GUI_COORD_LIMIT}"),
&n.to_string(),
)),
other => Err(crate::safety::bad_arg(
name,
&format!("{what}: Int"),
other.type_name(),
)),
}
}
/// The last kilobyte of a tool's stderr, for a coded error: a noisy tool
/// cannot flood the message.
fn gui_stderr_tail(output: &std::process::Output) -> String {
let owned = String::from_utf8_lossy(&output.stderr).into_owned();
let text = owned.trim();
let mut start = text.len().saturating_sub(1024);
while !text.is_char_boundary(start) {
start += 1;
}
text[start..].to_string()
}
/// The answer of a desktop action whose tool reports only success or failure:
/// `true` when it worked. A failure is a coded tool error, never a `false`
/// that could just as well mean the target was not there.
fn gui_action(name: &str, tool: &str, output: &std::process::Output) -> Result<Value> {
if output.status.success() {
Ok(Value::Bool(true))
} else {
Err(crate::safety::tool_failed(
name,
tool,
output.status.code(),
&gui_stderr_tail(output),
))
}
}
/// `gui_action` for a window operation. `missing` is the exit status the tool
/// or script uses when no window has the title: E_NOT_FOUND, not a failure.
fn gui_window_action(
name: &str,
tool: &str,
title: &str,
missing: i32,
output: &std::process::Output,
) -> Result<Value> {
if output.status.code() == Some(missing) {
return Err(crate::safety::not_found(name, "window", title));
}
gui_action(name, tool, output)
}
/// A dialog that answers with a path or text on stdout. Exit 0 is the answer
/// (an empty one is Null); `cancel` is the exit status of a user who closed
/// the dialog, which is Null too. Any other exit is a coded tool failure.
fn gui_dialog_answer(
name: &str,
tool: &str,
cancel: Option<i32>,
output: &std::process::Output,
) -> Result<Value> {
if output.status.success() {
let answer = String::from_utf8_lossy(&output.stdout)
.trim_end_matches(['\r', '\n'])
.trim()
.to_string();
return Ok(if answer.is_empty() {
Value::Null
} else {
Value::Str(answer)
});
}
if cancel.is_some() && output.status.code() == cancel {
return Ok(Value::Null);
}
Err(crate::safety::tool_failed(
name,
tool,
output.status.code(),
&gui_stderr_tail(output),
))
}
/// A confirmation dialog: `true` when it was acknowledged, `false` when the
/// user dismissed it (exit 1, which both zenity and osascript use for that).
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn gui_dialog_ack(name: &str, tool: &str, output: &std::process::Output) -> Result<Value> {
if output.status.success() {
return Ok(Value::Bool(true));
}
if output.status.code() == Some(1) {
return Ok(Value::Bool(false));
}
Err(crate::safety::tool_failed(
name,
tool,
output.status.code(),
&gui_stderr_tail(output),
))
}
/// A JSON answer on stdout, as a Value. A failed run, or an answer that is
/// not JSON, is a coded tool failure.
#[cfg(any(target_os = "windows", target_os = "macos"))]
fn gui_json_answer(name: &str, tool: &str, output: &std::process::Output) -> Result<Value> {
if !output.status.success() {
return Err(crate::safety::tool_failed(
name,
tool,
output.status.code(),
&gui_stderr_tail(output),
));
}
let text = String::from_utf8_lossy(&output.stdout).into_owned();
serde_json::from_str::<serde_json::Value>(text.trim())
.map(json_to_value)
.map_err(|e| {
crate::safety::tool_failed(
name,
tool,
Some(0),
&format!("its answer was not JSON: {e}"),
)
})
}
/// Runs a PowerShell script with no profile, no prompts, and errors that
/// stop the script (so a failed step exits nonzero instead of passing).
#[cfg(target_os = "windows")]
fn gui_powershell(name: &str, script: &str) -> Result<std::process::Output> {
let full = format!("$ErrorActionPreference = 'Stop'\n{script}");
let mut cmd = crate::safety::child_command("powershell");
cmd.args(["-NoProfile", "-NonInteractive", "-Command", &full]);
bounded_cmd_output(name, "powershell", cmd, TOOL_DEADLINE_SECS)
}
/// A key named by a key call: its modifiers, then one key.
#[derive(Clone, Copy)]
enum GuiMod {
Ctrl,
Alt,
Shift,
}
struct GuiKey {
mods: Vec<GuiMod>,
key: String,
}
/// Parses `ctrl+shift+t`, `enter`, `a`, `f5` and the like. Modifiers come
/// first, then exactly one key: a letter, a digit, or a name from
/// `GUI_NAMED_KEYS`. Anything else is refused before a tool runs.
fn parse_gui_key(name: &str, spec: &str) -> Result<GuiKey> {
const EXPECTED: &str = "a key such as enter, a, f5 or ctrl+shift+t";
if spec.len() > GUI_KEY_MAX_BYTES {
return Err(crate::safety::bad_arg(
name,
EXPECTED,
&format!("{} bytes", spec.len()),
));
}
let lowered = spec.to_ascii_lowercase();
let mut tokens: Vec<&str> = lowered.split('+').map(str::trim).collect();
let key = tokens.pop().unwrap_or("");
let mut mods = Vec::new();
for token in tokens {
mods.push(match token {
"ctrl" | "control" => GuiMod::Ctrl,
"alt" => GuiMod::Alt,
"shift" => GuiMod::Shift,
_ => return Err(crate::safety::bad_arg(name, EXPECTED, spec)),
});
}
let single = key.chars().count() == 1 && key.chars().all(|c| c.is_ascii_alphanumeric());
if !single && !GUI_NAMED_KEYS.contains(&key) {
return Err(crate::safety::bad_arg(name, EXPECTED, spec));
}
Ok(GuiKey {
mods,
key: key.to_string(),
})
}
/// The SendKeys spelling of a parsed key.
#[cfg(target_os = "windows")]
fn gui_sendkeys(key: &GuiKey) -> String {
let mut out = String::new();
for m in &key.mods {
out.push_str(match m {
GuiMod::Ctrl => "^",
GuiMod::Alt => "%",
GuiMod::Shift => "+",
});
}
let key_text = match key.key.as_str() {
"pageup" => "{PGUP}".to_string(),
"pagedown" => "{PGDN}".to_string(),
named if GUI_NAMED_KEYS.contains(&named) => format!("{{{}}}", named.to_ascii_uppercase()),
single => single.to_string(),
};
out.push_str(&key_text);
out
}
/// The SendKeys spelling of literal text: every character SendKeys treats as
/// syntax is sent as itself.
#[cfg(target_os = "windows")]
fn gui_sendkeys_text(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
match c {
'{' => out.push_str("{{}"),
'}' => out.push_str("{}}"),
'+' | '^' | '%' | '~' | '(' | ')' | '[' | ']' => {
out.push('{');
out.push(c);
out.push('}');
}
'\n' => out.push_str("{ENTER}"),
'\t' => out.push_str("{TAB}"),
other => out.push(other),
}
}
out
}
/// The xdotool spelling of a parsed key, e.g. `ctrl+shift+t` or `Return`.
#[cfg(target_os = "linux")]
fn gui_xdotool_key(key: &GuiKey) -> String {
let mut parts: Vec<String> = key
.mods
.iter()
.map(|m| match m {
GuiMod::Ctrl => "ctrl".to_string(),
GuiMod::Alt => "alt".to_string(),
GuiMod::Shift => "shift".to_string(),
})
.collect();
let key_name = match key.key.as_str() {
"enter" => "Return".to_string(),
"tab" => "Tab".to_string(),
"esc" => "Escape".to_string(),
"space" => "space".to_string(),
"backspace" => "BackSpace".to_string(),
"delete" => "Delete".to_string(),
"home" => "Home".to_string(),
"end" => "End".to_string(),
"pageup" => "Prior".to_string(),
"pagedown" => "Next".to_string(),
"up" => "Up".to_string(),
"down" => "Down".to_string(),
"left" => "Left".to_string(),
"right" => "Right".to_string(),
named if GUI_NAMED_KEYS.contains(&named) => named.to_ascii_uppercase(),
single => single.to_string(),
};
parts.push(key_name);
parts.join("+")
}
/// The Windows button codes for a mouse button, as (down, up).
#[cfg(target_os = "windows")]
fn gui_mouse_codes(button: &str) -> (u32, u32) {
match button {
"right" => (0x0008, 0x0010),
"middle" => (0x0020, 0x0040),
_ => (0x0002, 0x0004),
}
}
/// The button of a click: "left", "right" or "middle", or 1, 2 or 3 (left,
/// middle, right, as xdotool numbers them). Absent means left.
fn gui_button(name: &str, args: &[Value], index: usize) -> Result<&'static str> {
match args.get(index) {
None => Ok("left"),
Some(Value::Str(s)) => match s.as_str() {
"left" => Ok("left"),
"right" => Ok("right"),
"middle" => Ok("middle"),
_ => Err(crate::safety::bad_arg(
name,
"button: \"left\", \"right\" or \"middle\" (or 1, 2 or 3)",
s,
)),
},
Some(Value::Int(1)) => Ok("left"),
Some(Value::Int(2)) => Ok("middle"),
Some(Value::Int(3)) => Ok("right"),
Some(other) => Err(crate::safety::bad_arg(
name,
"button: \"left\", \"right\" or \"middle\" (or 1, 2 or 3)",
other.type_name(),
)),
}
}
/// Escapes a window title for xdotool's regular-expression `--name`.
#[cfg(target_os = "linux")]
fn gui_regex_escape(text: &str) -> String {
let mut out = String::with_capacity(text.len() * 2);
for c in text.chars() {
if ".^$*+?()[]{}|\\".contains(c) {
out.push('\\');
}
out.push(c);
}
out
}
/// Clipboard text as a String, refused above `CLIPBOARD_MAX_BYTES` rather than
/// returned in part.
fn clipboard_answer(name: &str, bytes: &[u8]) -> Result<Value> {
if bytes.len() > CLIPBOARD_MAX_BYTES {
return Err(crate::safety::budget_exceeded(
name,
format!(
"the clipboard holds {} bytes of text, more than the {CLIPBOARD_MAX_BYTES} this call returns",
bytes.len()
),
"read the clipboard with a platform tool that streams it",
));
}
Ok(Value::Str(String::from_utf8_lossy(bytes).into_owned()))
}
/// The osascript (JavaScript) behind the macOS window builtins. `argv[0]` picks
/// the operation: `list`, or a window operation whose target is `argv[1]`, the
/// exact window title, and whose numbers follow it. The title and numbers are
/// only ever arguments, never script text. A window that no System Events
/// process has exits 3, the not-found status the builtins map to E_NOT_FOUND.
/// Accessibility refusals (-1719, -25211) are thrown, so they reach the caller
/// as E_OS_DENIED rather than as a false "no such window".
#[cfg(target_os = "macos")]
const MACOS_WINDOW_ADMIN_JXA: &str = r#"ObjC.import('stdlib');
ObjC.import('AppKit');
ObjC.import('CoreGraphics');
ObjC.import('ApplicationServices');
function denied(e) {
return /-1719|-25211|assistive access/.test(String(e));
}
// The owner pids of on-screen ordinary windows titled exactly `title`.
function ownersOf(title) {
var all = ObjC.deepUnwrap(ObjC.castRefToObject($.CGWindowListCopyWindowInfo(17, 0))) || [];
var pids = [];
for (var k = 0; k < all.length; k++) {
if (all[k].kCGWindowLayer === 0 && all[k].kCGWindowName === title) {
pids.push(all[k].kCGWindowOwnerPID);
}
}
return pids;
}
// The first System Events window whose name is exactly `title`, as
// {proc, win}; null when no window has that name. The processes that own a
// window of that title in CGWindowList are asked first; every process is the
// fallback. Errors other than an Accessibility refusal skip a process, and
// are logged to stderr so a miss can be told from a refusal.
function findWindow(se, title) {
var procs = [];
var owners = ownersOf(title);
for (var o = 0; o < owners.length; o++) {
try {
procs = procs.concat(se.processes.whose({unixId: owners[o]})());
} catch (e) {
if (denied(e)) throw e;
console.log('pid ' + owners[o] + ': ' + e);
}
}
var asked = procs.length;
procs = procs.concat(se.processes());
// Trust was checked before this ran, so a refusal here is one process
// refusing, not ae being untrusted. From an owner of a window with this
// title it is fatal, since the window is there; from any other process it
// only skips that process. Treating every refusal as fatal made a title
// no window has unanswerable whenever one such process was running: the
// scan for a missing title is the one that reaches every process. The
// log line avoids the refusal markers, which the caller reads as E_OS_DENIED.
var skipped = 0;
for (var i = 0; i < procs.length; i++) {
var wins = [];
try {
wins = procs[i].windows();
} catch (e) {
if (denied(e) && i < asked) throw e;
if (denied(e)) skipped++;
if (i < asked) console.log('windows of owner: ' + e);
continue;
}
if (i < asked) console.log('owner has ' + wins.length + ' System Events windows');
for (var j = 0; j < wins.length; j++) {
var name;
try {
name = wins[j].name();
} catch (e) {
if (denied(e) && i < asked) throw e;
continue;
}
if (name === title) return {proc: procs[i], win: wins[j]};
}
}
if (skipped > 0) console.log(skipped + ' processes without the window title refused scripting; skipped');
return null;
}
function run(argv) {
var op = argv[0];
if (op === 'list') {
// Option 17 is kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements;
// 0 is kCGNullWindowID. Layer 0 is ordinary windows (not menus or the Dock).
var all = ObjC.deepUnwrap(ObjC.castRefToObject($.CGWindowListCopyWindowInfo(17, 0))) || [];
var out = [];
for (var k = 0; k < all.length; k++) {
var w = all[k];
if (w.kCGWindowLayer !== 0) continue;
if (typeof w.kCGWindowName !== 'string' || w.kCGWindowName === '') continue;
out.push({hwnd: w.kCGWindowNumber, title: w.kCGWindowName, pid: w.kCGWindowOwnerPID});
}
return JSON.stringify(out);
}
var title = argv[1];
// Without Accessibility trust System Events answers every process with
// no windows rather than an error, which would read as "no such window".
if (!$.AXIsProcessTrusted()) {
throw new Error('-25211 not allowed assistive access (AXIsProcessTrusted is false)');
}
var found = findWindow(Application('System Events'), title);
if (found === null) {
$.exit(3);
}
var win = found.win;
switch (op) {
case 'focus':
found.proc.frontmost = true;
win.actions.byName('AXRaise').perform();
break;
case 'minimize':
win.attributes.byName('AXMinimized').value = true;
break;
case 'maximize':
// System Events positions are top-left origin, measured from the
// primary display; Cocoa's visibleFrame is bottom-left origin.
var screen = $.NSScreen.mainScreen || $.NSScreen.screens.objectAtIndex(0);
var vf = screen.visibleFrame;
var primary = $.NSScreen.screens.objectAtIndex(0).frame;
win.position = [
Math.round(vf.origin.x),
Math.round(primary.size.height - (vf.origin.y + vf.size.height))
];
win.size = [Math.round(vf.size.width), Math.round(vf.size.height)];
break;
case 'close':
var buttons = win.buttons();
var close = null;
for (var b = 0; b < buttons.length; b++) {
if (buttons[b].subrole() === 'AXCloseButton') {
close = buttons[b];
break;
}
}
if (close === null) throw new Error('the window has no close button');
close.click();
break;
case 'move':
win.position = [Number(argv[2]), Number(argv[3])];
break;
case 'resize':
win.size = [Number(argv[2]), Number(argv[3])];
break;
default:
throw new Error('unknown window operation ' + op);
}
return 'ok';
}
"#;
/// Runs a JavaScript body with `/usr/bin/osascript -l JavaScript -e`, with
/// `argv` as its arguments. The arguments are never script text. An
/// Accessibility refusal (-1719, -25211) is E_OS_DENIED with `reason` and
/// `hint`; any other failure is left to the caller to report.
#[cfg(target_os = "macos")]
fn gui_macos_jxa(
name: &str,
script: &str,
argv: &[&str],
reason: &str,
hint: &str,
) -> Result<std::process::Output> {
let mut cmd = crate::safety::child_command("/usr/bin/osascript");
cmd.args(["-l", "JavaScript", "-e", script]);
cmd.args(argv);
let output = bounded_cmd_output(name, "osascript", cmd, TOOL_DEADLINE_SECS)?;
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success()
&& ["-1719", "-25211", "not allowed assistive access"]
.iter()
.any(|marker| stderr.contains(marker))
{
return Err(crate::safety::os_denied(name, reason, hint));
}
Ok(output)
}
/// Runs `MACOS_WINDOW_ADMIN_JXA` with `/usr/bin/osascript`: `op`, then the
/// operands as argv. Accessibility refusals are E_OS_DENIED with a hint.
#[cfg(target_os = "macos")]
fn gui_macos_window_admin(name: &str, op: &str, operands: &[&str]) -> Result<std::process::Output> {
let mut argv: Vec<&str> = vec![op];
argv.extend_from_slice(operands);
gui_macos_jxa(
name,
MACOS_WINDOW_ADMIN_JXA,
&argv,
"System Events is not allowed to control windows: Accessibility access is not granted to the process running ae",
"grant Accessibility in System Settings > Privacy & Security > Accessibility to the terminal or app that runs ae, then retry",
)
}
/// The osascript (JavaScript) behind the macOS input builtins. `argv[0]` picks
/// the operation: `move x y`, `click button`, `drag x1 y1 x2 y2`, `scroll
/// notches`, `pos`, `key code flags`, `combo code flags`, `type text`, `active`
/// or `screen`. Every number and name is validated in Rust before this runs,
/// and the text is only ever an argument. Each event goes through the HID event
/// tap. Posting events needs Accessibility, so the operations that post are
/// refused first, with the -25211 marker that maps to E_OS_DENIED, and nothing
/// is sent.
#[cfg(target_os = "macos")]
const MACOS_INPUT_ADMIN_JXA: &str = r#"ObjC.import('stdlib');
ObjC.import('AppKit');
ObjC.import('CoreGraphics');
ObjC.import('ApplicationServices');
// No ObjC.bindFunction: a signature the bridge rejects raises an NSException
// that JavaScript cannot catch, and osascript dies before any operation runs
// (run 40845668146). Only bridged functions are used, each checked first, so
// a missing one is a named failure of that operation alone.
function need(name) {
if (typeof $[name] !== 'function') {
throw new Error('-1 ' + name + ' is not available to JavaScript for Automation on this macOS');
}
return $[name];
}
var TRUST_NEEDED = {move: 1, click: 1, drag: 1, scroll: 1, key: 1, combo: 1, type: 1};
function post(ev) {
$.CGEventPost($.kCGHIDEventTap, ev);
}
function here() {
var p = $.CGEventGetLocation($.CGEventCreate(null));
return {x: p.x, y: p.y};
}
function mouse(type, x, y, button) {
post($.CGEventCreateMouseEvent(null, type, {x: x, y: y}, button));
}
function clickAt(button) {
var p = here();
if (button === 'right') {
mouse($.kCGEventRightMouseDown, p.x, p.y, $.kCGMouseButtonRight);
mouse($.kCGEventRightMouseUp, p.x, p.y, $.kCGMouseButtonRight);
} else if (button === 'middle') {
mouse($.kCGEventOtherMouseDown, p.x, p.y, $.kCGMouseButtonCenter);
mouse($.kCGEventOtherMouseUp, p.x, p.y, $.kCGMouseButtonCenter);
} else {
mouse($.kCGEventLeftMouseDown, p.x, p.y, $.kCGMouseButtonLeft);
mouse($.kCGEventLeftMouseUp, p.x, p.y, $.kCGMouseButtonLeft);
}
}
// A virtual key with its modifier flags set on the event, pressed or released.
function keyEvent(code, flags, down) {
var ev = $.CGEventCreateKeyboardEvent(null, code, down);
$.CGEventSetFlags(ev, flags);
post(ev);
}
// Types a chunk of at most 20 UTF-16 units as one keyboard event pair. The
// bridged CGEventKeyboardSetUnicodeString takes no usable UniChar buffer: a
// JavaScript array or Uint16Array is accepted and sets no text, so each chunk
// typed keycode 0, "a" (runs 40854789936, 40857315810), and an untyped Ref is
// refused. Bound with a void * buffer, it takes the bytes of UTF-16 NSData
// (run 40858670214). The bind is lazy and inside the caller's error handling:
// at the top level a rejected signature raised an NSException that killed the
// whole script (4fe8a17). The events are posted only if NSEvent reads back
// exactly the chunk, so a bridge that changes fails loudly, not with "a"s.
var boundSetText = false;
function readBack(ev) {
try {
var back = $.NSEvent.eventWithCGEvent(ev);
return back ? JSON.stringify(ObjC.unwrap(back.characters)) : 'no NSEvent';
} catch (e) {
return 'unreadable (' + e + ')';
}
}
function typeChunk(s) {
if (s.length === 0) return;
if (!boundSetText) {
ObjC.bindFunction('CGEventKeyboardSetUnicodeString',
['void', ['void *', 'unsigned long', 'void *']]);
boundSetText = true;
}
var data = $(s).dataUsingEncoding($.NSUTF16LittleEndianStringEncoding);
var down = $.CGEventCreateKeyboardEvent(null, 0, true);
var up = $.CGEventCreateKeyboardEvent(null, 0, false);
$.CGEventKeyboardSetUnicodeString(down, s.length, data.bytes);
$.CGEventKeyboardSetUnicodeString(up, s.length, data.bytes);
var got = readBack(down);
if (got !== JSON.stringify(s)) {
throw new Error('-1 the key event did not carry the text: it read back as ' + got);
}
post(down);
post(up);
}
// The text split into code points, so a surrogate pair is never cut.
function codePoints(text) {
var out = [];
for (var i = 0; i < text.length; i++) {
var c = text.charCodeAt(i);
if (c >= 0xD800 && c <= 0xDBFF && i + 1 < text.length) {
out.push(text.substr(i, 2));
i++;
} else {
out.push(text.charAt(i));
}
}
return out;
}
function typeText(text) {
var chunk = '';
var units = 0;
var cps = codePoints(text);
for (var k = 0; k < cps.length; k++) {
var cp = cps[k];
// Newline and tab are sent as the Return and Tab keys, as the other
// platforms do, not as characters.
if (cp === '\n' || cp === '\t') {
typeChunk(chunk);
chunk = '';
units = 0;
var code = cp === '\n' ? 36 : 48;
keyEvent(code, 0, true);
keyEvent(code, 0, false);
continue;
}
if (units + cp.length > 20) {
typeChunk(chunk);
chunk = '';
units = 0;
}
chunk += cp;
units += cp.length;
}
typeChunk(chunk);
}
// The frontmost application, and the title of its first System Events window
// when Accessibility is granted (null otherwise).
function activeWindow() {
var app = $.NSWorkspace.sharedWorkspace.frontmostApplication;
if (!app) return {app: null, pid: null, title: null};
var name = ObjC.unwrap(app.localizedName);
var pid = app.processIdentifier;
var title = null;
if ($.AXIsProcessTrusted()) {
try {
var procs = Application('System Events').processes.whose({unixId: pid})();
if (procs.length > 0) {
var wins = procs[0].windows();
if (wins.length > 0) title = wins[0].name();
}
} catch (e) {
if (/-1719|-25211|assistive access/.test(String(e))) throw e;
}
}
return {app: name, pid: pid, title: title};
}
function run(argv) {
var op = argv[0];
if (TRUST_NEEDED[op] && !$.AXIsProcessTrusted()) {
throw new Error('-25211 not allowed assistive access (AXIsProcessTrusted is false)');
}
switch (op) {
case 'move':
mouse($.kCGEventMouseMoved, Number(argv[1]), Number(argv[2]), $.kCGMouseButtonLeft);
return 'ok';
case 'click':
clickAt(argv[1]);
return 'ok';
case 'drag':
var x1 = Number(argv[1]), y1 = Number(argv[2]);
var x2 = Number(argv[3]), y2 = Number(argv[4]);
mouse($.kCGEventMouseMoved, x1, y1, $.kCGMouseButtonLeft);
mouse($.kCGEventLeftMouseDown, x1, y1, $.kCGMouseButtonLeft);
mouse($.kCGEventLeftMouseDragged, x2, y2, $.kCGMouseButtonLeft);
mouse($.kCGEventLeftMouseUp, x2, y2, $.kCGMouseButtonLeft);
return 'ok';
case 'scroll':
// Positive notches scroll up, as on the other platforms.
// CGEventCreateScrollWheelEvent2 is the non-variadic form the bridge
// exposes: source, units (1 = line), wheel count, then three deltas.
post(need('CGEventCreateScrollWheelEvent2')(null, 1, 1, Number(argv[1]), 0, 0));
return 'ok';
case 'pos':
var p = here();
return JSON.stringify({x: Math.round(p.x), y: Math.round(p.y)});
case 'key':
case 'combo':
var code = Number(argv[1]);
var flags = Number(argv[2]);
keyEvent(code, flags, true);
keyEvent(code, flags, false);
return 'ok';
case 'type':
typeText(argv[1]);
return 'ok';
case 'active':
return JSON.stringify(activeWindow());
case 'screen':
var screen = $.NSScreen.mainScreen || $.NSScreen.screens.objectAtIndex(0);
var frame = screen.frame;
return JSON.stringify({
width: Math.round(frame.size.width),
height: Math.round(frame.size.height)
});
default:
throw new Error('unknown input operation ' + op);
}
}
"#;
/// The macOS virtual keycode of a key name that `parse_gui_key` accepted. The
/// codes are the ANSI key positions of Carbon's HIToolbox `kVK_*` constants
/// (Events.h), which is the US layout, so a letter or digit names its key in
/// that layout. `delete` is the forward-delete key, as on Windows and Linux;
/// `backspace` is the key labelled delete on Mac keyboards.
#[cfg(target_os = "macos")]
fn macos_key_code(name: &str, key: &str) -> Result<u16> {
Ok(match key {
"a" => 0,
"s" => 1,
"d" => 2,
"f" => 3,
"h" => 4,
"g" => 5,
"z" => 6,
"x" => 7,
"c" => 8,
"v" => 9,
"b" => 11,
"q" => 12,
"w" => 13,
"e" => 14,
"r" => 15,
"y" => 16,
"t" => 17,
"1" => 18,
"2" => 19,
"3" => 20,
"4" => 21,
"6" => 22,
"5" => 23,
"9" => 25,
"7" => 26,
"8" => 28,
"0" => 29,
"o" => 31,
"u" => 32,
"i" => 34,
"p" => 35,
"l" => 37,
"j" => 38,
"k" => 40,
"n" => 45,
"m" => 46,
"enter" => 36,
"tab" => 48,
"space" => 49,
"backspace" => 51,
"esc" => 53,
"delete" => 117,
"home" => 115,
"end" => 119,
"pageup" => 116,
"pagedown" => 121,
"left" => 123,
"right" => 124,
"down" => 125,
"up" => 126,
"f1" => 122,
"f2" => 120,
"f3" => 99,
"f4" => 118,
"f5" => 96,
"f6" => 97,
"f7" => 98,
"f8" => 100,
"f9" => 101,
"f10" => 109,
"f11" => 103,
"f12" => 111,
_ => {
return Err(crate::safety::bad_arg(
name,
"a key such as enter, a, f5 or ctrl+shift+t",
key,
))
}
})
}
/// The CGEventFlags mask of a key's modifiers: Shift, Control and Option.
#[cfg(target_os = "macos")]
fn macos_key_flags(mods: &[GuiMod]) -> u64 {
mods.iter().fold(0, |flags, m| {
flags
| match m {
GuiMod::Shift => 0x0002_0000,
GuiMod::Ctrl => 0x0004_0000,
GuiMod::Alt => 0x0008_0000,
}
})
}
/// Runs `MACOS_INPUT_ADMIN_JXA`: `op`, then the operands as argv. Without
/// Accessibility the operations that post events are E_OS_DENIED.
#[cfg(target_os = "macos")]
fn gui_macos_input(name: &str, op: &str, operands: &[String]) -> Result<std::process::Output> {
let mut argv: Vec<&str> = vec![op];
argv.extend(operands.iter().map(String::as_str));
gui_macos_jxa(
name,
MACOS_INPUT_ADMIN_JXA,
&argv,
"Accessibility access is not granted to the process running ae, so mouse and keyboard events are not sent",
"grant Accessibility in System Settings > Privacy & Security > Accessibility to the terminal or app that runs ae, then retry",
)
}
#[allow(unused_variables)]
fn bi_gui_list_windows(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
no_args_or_input("gui_list_windows", &args, &input)?;
#[cfg(target_os = "windows")]
{
let script = r#"
Add-Type @"
using System;
using System.Runtime.InteropServices;
using System.Text;
using System.Collections.Generic;
public class WindowHelper {
[DllImport("user32.dll")]
public static extern bool EnumWindows(EnumWindowsProc enumProc, IntPtr lParam);
[DllImport("user32.dll")]
public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
[DllImport("user32.dll")]
public static extern bool IsWindowVisible(IntPtr hWnd);
[DllImport("user32.dll")]
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
public static List<object> windows = new List<object>();
public static bool EnumWindow(IntPtr hWnd, IntPtr lParam) {
if (!IsWindowVisible(hWnd)) return true;
StringBuilder title = new StringBuilder(256);
GetWindowText(hWnd, title, 256);
if (title.Length == 0) return true;
uint pid;
GetWindowThreadProcessId(hWnd, out pid);
windows.Add(new { hwnd = hWnd.ToInt64(), title = title.ToString(), pid = pid });
return true;
}
}
"@
[WindowHelper]::windows.Clear()
[WindowHelper]::EnumWindows([WindowHelper+EnumWindowsProc]{ param($h,$l) [WindowHelper]::EnumWindow($h,$l) }, [IntPtr]::Zero) | Out-Null
ConvertTo-Json -InputObject @([WindowHelper]::windows)
"#;
let output = gui_powershell("gui_list_windows", script)?;
gui_json_answer("gui_list_windows", "powershell", &output)
}
#[cfg(target_os = "linux")]
{
let mut cmd = crate::safety::child_command("wmctrl");
cmd.arg("-l");
let output = bounded_cmd_output("gui_list_windows", "wmctrl", cmd, TOOL_DEADLINE_SECS)?;
if !output.status.success() {
return Err(crate::safety::tool_failed(
"gui_list_windows",
"wmctrl",
output.status.code(),
&gui_stderr_tail(&output),
));
}
let text = String::from_utf8_lossy(&output.stdout).into_owned();
let mut windows = Vec::new();
for line in text.lines() {
// `wmctrl -l` pads its columns: window id, desktop, host, then the
// title to the end of the line.
let mut rest = line.trim_start();
let mut fields = [""; 3];
let mut complete = true;
for field in fields.iter_mut() {
match rest.split_once(char::is_whitespace) {
Some((head, tail)) => {
*field = head;
rest = tail.trim_start();
}
None => {
complete = false;
break;
}
}
}
if !complete {
continue;
}
let mut rec = std::collections::BTreeMap::new();
rec.insert("hwnd".to_string(), Value::Str(fields[0].to_string()));
rec.insert("title".to_string(), Value::Str(rest.trim_end().to_string()));
windows.push(Value::Record(rec));
}
Ok(Value::Array(windows))
}
#[cfg(target_os = "macos")]
{
let output = gui_macos_window_admin("gui_list_windows", "list", &[])?;
gui_json_answer("gui_list_windows", "osascript", &output)
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
Err(crate::safety::unimplemented(
"gui_list_windows",
"listing windows is implemented on Windows and Linux; NOTHING WAS READ",
"run this on Windows or Linux",
))
}
}
#[allow(unused_variables)]
fn bi_gui_focus_window(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "gui_focus_window";
refuse_piped_input(NAME, &input)?;
let (label, handle) = match gui_required(NAME, &args, 0, "a window title or handle")? {
Value::Int(h) if *h > 0 => (h.to_string(), Some(*h)),
Value::Int(h) => {
return Err(crate::safety::bad_arg(
NAME,
"a window handle above 0",
&h.to_string(),
))
}
v @ Value::Str(_) => (
gui_text(NAME, "window title", v, GUI_FIELD_MAX_BYTES, false)?,
None,
),
other => {
return Err(crate::safety::bad_arg(
NAME,
"Int or String",
other.type_name(),
))
}
};
#[cfg(target_os = "windows")]
{
let (is_handle, arg) = match handle {
Some(h) => (1, crate::safety::ps_quote(&h.to_string())),
None => (0, crate::safety::ps_quote(&label)),
};
let script = crate::ps_script!(
r#"
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class WindowFocus {{
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")] public static extern IntPtr FindWindow(string lpClassName, string lpWindowName);
}}
"@
$hwnd = if ({} -eq 1) {{ [IntPtr]::new([int64]{}) }} else {{ [WindowFocus]::FindWindow([NullString]::Value, {}) }}
if ($hwnd -eq [IntPtr]::Zero) {{ exit 3 }}
if (-not [WindowFocus]::SetForegroundWindow($hwnd)) {{ exit 4 }}
"#,
is_handle,
arg,
arg
);
let output = gui_powershell(NAME, &script)?;
gui_window_action(NAME, "powershell", &label, 3, &output)
}
#[cfg(target_os = "linux")]
{
let args: Vec<String> = match handle {
Some(h) => vec!["-i".into(), "-a".into(), format!("0x{h:x}")],
None => {
crate::safety::reject_option_like(NAME, std::slice::from_ref(&label))?;
vec!["-a".into(), label.clone()]
}
};
let mut cmd = crate::safety::child_command("wmctrl");
cmd.args(&args);
let output = bounded_cmd_output(NAME, "wmctrl", cmd, TOOL_DEADLINE_SECS)?;
gui_window_action(NAME, "wmctrl", &label, 1, &output)
}
#[cfg(target_os = "macos")]
{
if handle.is_some() {
return Err(crate::safety::unimplemented(
NAME,
"focusing a window by its handle is not supported on macOS: handles are CGWindow numbers from gui_list_windows; NOTHING WAS DONE",
"focus the window by its exact title",
));
}
let output = gui_macos_window_admin(NAME, "focus", &[label.as_str()])?;
gui_window_action(NAME, "osascript", &label, 3, &output)
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
Err(crate::safety::unimplemented(
NAME,
"desktop automation is not implemented on this platform; NOTHING WAS DONE",
"run this on Windows or Linux",
))
}
}
#[allow(unused_variables)]
fn bi_gui_minimize_window(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "gui_minimize_window";
refuse_piped_input(NAME, &input)?;
let title = gui_text(
NAME,
"window title",
gui_required(NAME, &args, 0, "a window title")?,
GUI_FIELD_MAX_BYTES,
false,
)?;
#[cfg(target_os = "windows")]
{
let script = crate::ps_script!(
r#"
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class WindowMin {{
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll")] public static extern IntPtr FindWindow(string lpClassName, string lpWindowName);
}}
"@
$hwnd = [WindowMin]::FindWindow([NullString]::Value, {})
if ($hwnd -eq [IntPtr]::Zero) {{ exit 3 }}
[WindowMin]::ShowWindow($hwnd, 6) | Out-Null
"#,
crate::safety::ps_quote(&title)
);
let output = gui_powershell(NAME, &script)?;
gui_window_action(NAME, "powershell", &title, 3, &output)
}
#[cfg(target_os = "linux")]
{
crate::safety::reject_option_like(NAME, std::slice::from_ref(&title))?;
let pattern = format!("^{}$", gui_regex_escape(&title));
let mut cmd = crate::safety::child_command("xdotool");
cmd.args([
"search",
"--limit",
"1",
"--name",
&pattern,
"windowminimize",
]);
let output = bounded_cmd_output(NAME, "xdotool", cmd, TOOL_DEADLINE_SECS)?;
gui_window_action(NAME, "xdotool", &title, 1, &output)
}
#[cfg(target_os = "macos")]
{
let output = gui_macos_window_admin(NAME, "minimize", &[title.as_str()])?;
gui_window_action(NAME, "osascript", &title, 3, &output)
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
Err(crate::safety::unimplemented(
NAME,
"desktop automation is not implemented on this platform; NOTHING WAS DONE",
"run this on Windows or Linux",
))
}
}
#[allow(unused_variables)]
fn bi_gui_maximize_window(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "gui_maximize_window";
refuse_piped_input(NAME, &input)?;
let title = gui_text(
NAME,
"window title",
gui_required(NAME, &args, 0, "a window title")?,
GUI_FIELD_MAX_BYTES,
false,
)?;
#[cfg(target_os = "windows")]
{
let script = crate::ps_script!(
r#"
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class WindowMax {{
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll")] public static extern IntPtr FindWindow(string lpClassName, string lpWindowName);
}}
"@
$hwnd = [WindowMax]::FindWindow([NullString]::Value, {})
if ($hwnd -eq [IntPtr]::Zero) {{ exit 3 }}
[WindowMax]::ShowWindow($hwnd, 3) | Out-Null
"#,
crate::safety::ps_quote(&title)
);
let output = gui_powershell(NAME, &script)?;
gui_window_action(NAME, "powershell", &title, 3, &output)
}
#[cfg(target_os = "linux")]
{
crate::safety::reject_option_like(NAME, std::slice::from_ref(&title))?;
let mut cmd = crate::safety::child_command("wmctrl");
cmd.args(["-r", &title, "-b", "add,maximized_vert,maximized_horz"]);
let output = bounded_cmd_output(NAME, "wmctrl", cmd, TOOL_DEADLINE_SECS)?;
gui_window_action(NAME, "wmctrl", &title, 1, &output)
}
#[cfg(target_os = "macos")]
{
let output = gui_macos_window_admin(NAME, "maximize", &[title.as_str()])?;
gui_window_action(NAME, "osascript", &title, 3, &output)
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
Err(crate::safety::unimplemented(
NAME,
"desktop automation is not implemented on this platform; NOTHING WAS DONE",
"run this on Windows or Linux",
))
}
}
#[allow(unused_variables)]
fn bi_gui_close_window(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "gui_close_window";
refuse_piped_input(NAME, &input)?;
let title = gui_text(
NAME,
"window title",
gui_required(NAME, &args, 0, "a window title")?,
GUI_FIELD_MAX_BYTES,
false,
)?;
#[cfg(target_os = "windows")]
{
let script = crate::ps_script!(
r#"
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class WindowClose {{
[DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll")] public static extern IntPtr FindWindow(string lpClassName, string lpWindowName);
}}
"@
$hwnd = [WindowClose]::FindWindow([NullString]::Value, {})
if ($hwnd -eq [IntPtr]::Zero) {{ exit 3 }}
[WindowClose]::SendMessage($hwnd, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero) | Out-Null
"#,
crate::safety::ps_quote(&title)
);
let output = gui_powershell(NAME, &script)?;
gui_window_action(NAME, "powershell", &title, 3, &output)
}
#[cfg(target_os = "linux")]
{
crate::safety::reject_option_like(NAME, std::slice::from_ref(&title))?;
let mut cmd = crate::safety::child_command("wmctrl");
cmd.args(["-c", &title]);
let output = bounded_cmd_output(NAME, "wmctrl", cmd, TOOL_DEADLINE_SECS)?;
gui_window_action(NAME, "wmctrl", &title, 1, &output)
}
#[cfg(target_os = "macos")]
{
let output = gui_macos_window_admin(NAME, "close", &[title.as_str()])?;
gui_window_action(NAME, "osascript", &title, 3, &output)
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
Err(crate::safety::unimplemented(
NAME,
"desktop automation is not implemented on this platform; NOTHING WAS DONE",
"run this on Windows or Linux",
))
}
}
#[allow(unused_variables)]
fn bi_gui_move_window(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "gui_move_window";
refuse_piped_input(NAME, &input)?;
let title = gui_text(
NAME,
"window title",
gui_required(NAME, &args, 0, "a window title")?,
GUI_FIELD_MAX_BYTES,
false,
)?;
let x = gui_coord(NAME, "x", gui_required(NAME, &args, 1, "an Int x")?)?;
let y = gui_coord(NAME, "y", gui_required(NAME, &args, 2, "an Int y")?)?;
#[cfg(target_os = "windows")]
{
let script = crate::ps_script!(
r#"
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class WindowMove {{
[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
[DllImport("user32.dll")] public static extern IntPtr FindWindow(string lpClassName, string lpWindowName);
}}
"@
$hwnd = [WindowMove]::FindWindow([NullString]::Value, {})
if ($hwnd -eq [IntPtr]::Zero) {{ exit 3 }}
if (-not [WindowMove]::SetWindowPos($hwnd, [IntPtr]::Zero, {}, {}, 0, 0, 0x0001)) {{ exit 4 }}
"#,
crate::safety::ps_quote(&title),
x,
y
);
let output = gui_powershell(NAME, &script)?;
gui_window_action(NAME, "powershell", &title, 3, &output)
}
#[cfg(target_os = "linux")]
{
crate::safety::reject_option_like(NAME, std::slice::from_ref(&title))?;
let mut cmd = crate::safety::child_command("wmctrl");
cmd.args(["-r", &title, "-e", &format!("0,{x},{y},-1,-1")]);
let output = bounded_cmd_output(NAME, "wmctrl", cmd, TOOL_DEADLINE_SECS)?;
gui_window_action(NAME, "wmctrl", &title, 1, &output)
}
#[cfg(target_os = "macos")]
{
let (xs, ys) = (x.to_string(), y.to_string());
let output =
gui_macos_window_admin(NAME, "move", &[title.as_str(), xs.as_str(), ys.as_str()])?;
gui_window_action(NAME, "osascript", &title, 3, &output)
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
Err(crate::safety::unimplemented(
NAME,
"desktop automation is not implemented on this platform; NOTHING WAS DONE",
"run this on Windows or Linux",
))
}
}
#[allow(unused_variables)]
fn bi_gui_resize_window(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "gui_resize_window";
refuse_piped_input(NAME, &input)?;
let title = gui_text(
NAME,
"window title",
gui_required(NAME, &args, 0, "a window title")?,
GUI_FIELD_MAX_BYTES,
false,
)?;
let w = gui_size(NAME, "width", gui_required(NAME, &args, 1, "an Int width")?)?;
let h = gui_size(
NAME,
"height",
gui_required(NAME, &args, 2, "an Int height")?,
)?;
#[cfg(target_os = "windows")]
{
let script = crate::ps_script!(
r#"
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class WindowResize {{
[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
[DllImport("user32.dll")] public static extern IntPtr FindWindow(string lpClassName, string lpWindowName);
}}
"@
$hwnd = [WindowResize]::FindWindow([NullString]::Value, {})
if ($hwnd -eq [IntPtr]::Zero) {{ exit 3 }}
if (-not [WindowResize]::SetWindowPos($hwnd, [IntPtr]::Zero, 0, 0, {}, {}, 0x0002)) {{ exit 4 }}
"#,
crate::safety::ps_quote(&title),
w,
h
);
let output = gui_powershell(NAME, &script)?;
gui_window_action(NAME, "powershell", &title, 3, &output)
}
#[cfg(target_os = "linux")]
{
crate::safety::reject_option_like(NAME, std::slice::from_ref(&title))?;
let mut cmd = crate::safety::child_command("wmctrl");
cmd.args(["-r", &title, "-e", &format!("0,-1,-1,{w},{h}")]);
let output = bounded_cmd_output(NAME, "wmctrl", cmd, TOOL_DEADLINE_SECS)?;
gui_window_action(NAME, "wmctrl", &title, 1, &output)
}
#[cfg(target_os = "macos")]
{
let (ws, hs) = (w.to_string(), h.to_string());
let output =
gui_macos_window_admin(NAME, "resize", &[title.as_str(), ws.as_str(), hs.as_str()])?;
gui_window_action(NAME, "osascript", &title, 3, &output)
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
Err(crate::safety::unimplemented(
NAME,
"desktop automation is not implemented on this platform; NOTHING WAS DONE",
"run this on Windows or Linux",
))
}
}
#[allow(unused_variables)]
fn bi_gui_screenshot(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "gui_screenshot";
refuse_piped_input(NAME, &input)?;
let path = match args.first() {
None => {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("screenshot_{}.png", ts)
}
Some(v) => gui_text(NAME, "screenshot path", v, GUI_FIELD_MAX_BYTES, false)?,
};
// Where it writes is checked like any other write, and the path goes to
// scrot/screencapture positionally.
crate::safety::reject_option_like(NAME, std::slice::from_ref(&path))?;
let path = guard_local_write(NAME, &path)?;
#[cfg(target_os = "windows")]
{
let script = crate::ps_script!(
r#"
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$screen = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
$bitmap = New-Object System.Drawing.Bitmap($screen.Width, $screen.Height)
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$graphics.CopyFromScreen($screen.Location, [System.Drawing.Point]::Empty, $screen.Size)
$bitmap.Save({})
$graphics.Dispose()
$bitmap.Dispose()
"#,
crate::safety::ps_quote(&path)
);
let output = gui_powershell(NAME, &script)?;
if output.status.success() {
Ok(Value::Str(path))
} else {
// A failed capture answered null.
Err(crate::safety::tool_failed(
NAME,
"powershell",
output.status.code(),
&gui_stderr_tail(&output),
))
}
}
#[cfg(target_os = "linux")]
{
let mut cmd = crate::safety::child_command("scrot");
cmd.arg(&path);
let output = bounded_cmd_output(NAME, "scrot", cmd, TOOL_DEADLINE_SECS)?;
if output.status.success() {
Ok(Value::Str(path))
} else {
Err(crate::safety::tool_failed(
NAME,
"scrot",
output.status.code(),
&gui_stderr_tail(&output),
))
}
}
#[cfg(target_os = "macos")]
{
let mut cmd = crate::safety::child_command("screencapture");
cmd.args(["-x", &path]);
let output = bounded_cmd_output(NAME, "screencapture", cmd, TOOL_DEADLINE_SECS)?;
if output.status.success() {
Ok(Value::Str(path))
} else {
Err(crate::safety::tool_failed(
NAME,
"screencapture",
output.status.code(),
&gui_stderr_tail(&output),
))
}
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
Err(crate::safety::unimplemented(
NAME,
"screen capture is not implemented on this OS; NOTHING WAS CAPTURED",
"use the platform's screenshot tool",
))
}
}
/// The PowerShell that captures one window on Windows. It is a constant: the
/// title and the output path arrive in the AE_SHOT_TITLE and AE_SHOT_PATH
/// environment variables, never in the script text. The exit codes are the
/// contract: 3 no window has the title (E_NOT_FOUND), 4 the window is minimized
/// or has no size, 5 PrintWindow drew nothing, 6 the PNG could not be saved.
/// The process is per-monitor-DPI-unaware by default, which would measure the
/// window in scaled pixels and draw it smaller, so it declares itself aware
/// first and the rectangle and the bitmap are both in physical pixels.
#[cfg(target_os = "windows")]
const WINDOW_SHOT_SCRIPT: &str = r#"$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class WindowShot {
[StructLayout(LayoutKind.Sequential)]
public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
[DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern IntPtr FindWindow(string lpClassName, string lpWindowName);
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd);
[DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr hWnd, IntPtr hdcBlt, uint nFlags);
}
"@
[void][WindowShot]::SetProcessDPIAware()
$title = [Environment]::GetEnvironmentVariable('AE_SHOT_TITLE')
$out = [Environment]::GetEnvironmentVariable('AE_SHOT_PATH')
$hwnd = [WindowShot]::FindWindow([NullString]::Value, $title)
if ($hwnd -eq [IntPtr]::Zero) { exit 3 }
if ([WindowShot]::IsIconic($hwnd)) {
[Console]::Error.WriteLine('the window is minimized, so it has no picture to capture')
exit 4
}
$rect = New-Object 'WindowShot+RECT'
if (-not [WindowShot]::GetWindowRect($hwnd, [ref]$rect)) {
[Console]::Error.WriteLine('GetWindowRect did not measure the window')
exit 4
}
$width = $rect.Right - $rect.Left
$height = $rect.Bottom - $rect.Top
if ($width -lt 1 -or $height -lt 1) {
[Console]::Error.WriteLine("the window measures ${width}x${height}")
exit 4
}
try {
$bitmap = New-Object System.Drawing.Bitmap($width, $height)
try {
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
try {
$hdc = $graphics.GetHdc()
try {
$drawn = [WindowShot]::PrintWindow($hwnd, $hdc, 2)
} finally {
$graphics.ReleaseHdc($hdc)
}
} finally {
$graphics.Dispose()
}
if (-not $drawn) {
[Console]::Error.WriteLine('PrintWindow did not draw the window')
exit 5
}
$bitmap.Save($out, [System.Drawing.Imaging.ImageFormat]::Png)
} finally {
$bitmap.Dispose()
}
} catch {
[Console]::Error.WriteLine($_.Exception.Message)
exit 6
}
exit 0
"#;
/// The JavaScript for osascript that finds a window's CGWindowID by its exact
/// title. The title arrives in AE_SHOT_TITLE. Without Screen Recording
/// permission macOS withholds every other process's window titles, so a title
/// that is not found while no other process's window has a title answers
/// `denied`, not a false `missing`. (CGPreflightScreenCaptureAccess is not
/// reachable from JXA.) Otherwise it answers `missing`, or `id <n>`.
#[cfg(target_os = "macos")]
const MACOS_WINDOW_ID_JXA: &str = r#"ObjC.import('CoreGraphics');
ObjC.import('Foundation');
function run() {
var title = ObjC.unwrap($.NSProcessInfo.processInfo.environment.objectForKey('AE_SHOT_TITLE'));
var me = $.NSProcessInfo.processInfo.processIdentifier;
// Option 0 is kCGWindowListOptionAll; relative 0 is kCGNullWindowID. The
// answer is a CFArrayRef, which deepUnwrap reads only once it is cast to
// an object.
var windows = ObjC.deepUnwrap(ObjC.castRefToObject($.CGWindowListCopyWindowInfo(0, 0))) || [];
var named = 0;
for (var i = 0; i < windows.length; i++) {
var w = windows[i];
if (w.kCGWindowName === title) { return 'id ' + w.kCGWindowNumber; }
if (w.kCGWindowName !== undefined && w.kCGWindowOwnerPID !== me) { named++; }
}
return named === 0 ? 'denied' : 'missing';
}
"#;
/// The answer of a capture tool that wrote `path`: the file must exist and
/// begin with the PNG signature, or the capture is a coded failure. A tool that
/// exits 0 having written nothing is not a capture.
#[cfg(any(target_os = "windows", target_os = "macos"))]
fn gui_png_answer(name: &str, tool: &str, path: String) -> Result<Value> {
let bytes = fs::read(&path).map_err(|e| {
crate::safety::tool_failed(
name,
tool,
Some(0),
&format!("it wrote no readable file at {path}: {e}"),
)
})?;
if image_mime_of(&bytes) != Some("image/png") {
return Err(crate::safety::tool_failed(
name,
tool,
Some(0),
"its output was not a PNG image",
));
}
Ok(Value::Str(path))
}
/// `gui_screenshot_window(title, path?)`: capture the window whose title is
/// exactly `title` as a PNG at `path` (default `window_<seconds>.png`) and
/// answer the path. The path is checked like any other write. Linux X11:
/// xwininfo finds the window, ImageMagick's `import` captures it to stdout,
/// and this shell writes the file itself, so `import` never interprets the
/// path (it would run a name that begins with `|` as a command). Windows:
/// user32 FindWindow matches the exact title and PrintWindow draws it (see
/// `WINDOW_SHOT_SCRIPT`). macOS: the title is resolved to a CGWindowID and
/// `screencapture -l` captures that window; no Screen Recording permission is
/// E_OS_DENIED, never a blank PNG.
fn bi_gui_screenshot_window(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "gui_screenshot_window";
refuse_piped_input(NAME, &input)?;
let title = gui_text(
NAME,
"window title",
gui_required(NAME, &args, 0, "a window title")?,
GUI_FIELD_MAX_BYTES,
false,
)?;
if args.len() > 2 {
return Err(crate::safety::bad_arg(
NAME,
"at most two arguments: the window title and the PNG path",
&format!("{} arguments", args.len()),
));
}
let path = match args.get(1) {
None => {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("window_{ts}.png")
}
Some(v) => gui_text(NAME, "screenshot path", v, GUI_FIELD_MAX_BYTES, false)?,
};
#[cfg(target_os = "linux")]
{
if !display_env_set("DISPLAY") {
if display_env_set("WAYLAND_DISPLAY") {
return Err(crate::safety::unimplemented(
NAME,
"capturing one window is implemented for X11 only; under Wayland NOTHING WAS CAPTURED",
"gui_screenshot(path) captures the whole screen",
));
}
return Err(crate::safety::no_ui(NAME));
}
let path = guard_local_write(NAME, &path)?;
let mut find = crate::safety::child_command("xwininfo");
find.args(["-name", &title]);
let found = bounded_cmd_output(NAME, "xwininfo", find, PROBE_DEADLINE_SECS)?;
let stdout = String::from_utf8_lossy(&found.stdout);
let stderr = String::from_utf8_lossy(&found.stderr);
if !found.status.success() {
if stderr.contains("No window with name") {
return Err(crate::safety::not_found(NAME, "window", &title));
}
return Err(crate::safety::tool_failed(
NAME,
"xwininfo",
found.status.code(),
&gui_stderr_tail(&found),
));
}
// "xwininfo: Window id: 0x20001d "title"": the id is the hex word
// after "Window id:".
let id = stdout
.lines()
.find_map(|l| l.split_once("Window id:").map(|(_, rest)| rest.trim()))
.and_then(|rest| rest.split_whitespace().next())
.filter(|id| {
id.strip_prefix("0x").is_some_and(|hex| {
!hex.is_empty() && hex.bytes().all(|b| b.is_ascii_hexdigit())
})
})
.map(str::to_string)
.ok_or_else(|| {
crate::safety::tool_failed(
NAME,
"xwininfo",
Some(0),
"its output named no window id",
)
})?;
let mut capture = crate::safety::child_command("import");
capture.args(["-window", &id, "png:-"]);
let shot = bounded_cmd_output(NAME, "import", capture, TOOL_DEADLINE_SECS)?;
if !shot.status.success() {
return Err(crate::safety::tool_failed(
NAME,
"import",
shot.status.code(),
&gui_stderr_tail(&shot),
));
}
if image_mime_of(&shot.stdout) != Some("image/png") {
return Err(crate::safety::tool_failed(
NAME,
"import",
Some(0),
"its output was not a PNG image",
));
}
crate::tx::snapshot(&path); // record pre-capture state if a transaction is active
fs::write(&path, &shot.stdout).map_err(|e| crate::safety::fs_error(NAME, &path, &e))?;
Ok(Value::Str(path))
}
#[cfg(target_os = "windows")]
{
let path = guard_local_write(NAME, &path)?;
let mut cmd = crate::safety::child_command("powershell");
cmd.args([
"-NoProfile",
"-NonInteractive",
"-Command",
WINDOW_SHOT_SCRIPT,
]);
cmd.env("AE_SHOT_TITLE", &title).env("AE_SHOT_PATH", &path);
crate::tx::snapshot(&path); // record pre-capture state if a transaction is active
let shot = bounded_cmd_output(NAME, "powershell", cmd, TOOL_DEADLINE_SECS)?;
match shot.status.code() {
Some(0) => gui_png_answer(NAME, "powershell", path),
Some(3) => Err(crate::safety::not_found(NAME, "window", &title)),
code => Err(crate::safety::tool_failed(
NAME,
"powershell",
code,
&gui_stderr_tail(&shot),
)),
}
}
#[cfg(target_os = "macos")]
{
let path = guard_local_write(NAME, &path)?;
let mut find = crate::safety::child_command("osascript");
find.args(["-l", "JavaScript", "-e", MACOS_WINDOW_ID_JXA]);
find.env("AE_SHOT_TITLE", &title);
let found = bounded_cmd_output(NAME, "osascript", find, PROBE_DEADLINE_SECS)?;
if !found.status.success() {
return Err(crate::safety::tool_failed(
NAME,
"osascript",
found.status.code(),
&gui_stderr_tail(&found),
));
}
let answer = String::from_utf8_lossy(&found.stdout).trim().to_string();
let id: u64 = match answer.as_str() {
"denied" => {
return Err(crate::safety::os_denied(
NAME,
"Screen Recording permission is not granted to the process running ae, so window titles and pixels are withheld",
"grant Screen Recording to the terminal or app that runs ish in System Settings > Privacy & Security, then retry",
))
}
"missing" => return Err(crate::safety::not_found(NAME, "window", &title)),
other => other
.strip_prefix("id ")
.and_then(|n| n.parse().ok())
.ok_or_else(|| {
crate::safety::tool_failed(
NAME,
"osascript",
Some(0),
"its output named no window id",
)
})?,
};
crate::tx::snapshot(&path); // record pre-capture state if a transaction is active
let mut capture = crate::safety::child_command("screencapture");
capture.args(["-x", "-o", "-l", &id.to_string(), &path]);
let shot = bounded_cmd_output(NAME, "screencapture", capture, TOOL_DEADLINE_SECS)?;
if !shot.status.success() {
return Err(crate::safety::tool_failed(
NAME,
"screencapture",
shot.status.code(),
&gui_stderr_tail(&shot),
));
}
gui_png_answer(NAME, "screencapture", path)
}
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
{
let _ = (title, path);
Err(crate::safety::unimplemented(
NAME,
"capturing a window by title is implemented on Linux (X11), Windows and macOS only; NOTHING WAS CAPTURED",
"gui_screenshot(path) captures the whole screen",
))
}
}
#[allow(unused_variables)]
fn bi_gui_mouse_move(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "gui_mouse_move";
refuse_piped_input(NAME, &input)?;
let x = gui_coord(NAME, "x", gui_required(NAME, &args, 0, "an Int x")?)?;
let y = gui_coord(NAME, "y", gui_required(NAME, &args, 1, "an Int y")?)?;
#[cfg(target_os = "windows")]
{
let script = crate::ps_script!(
r#"
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class MouseMove {{
[DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y);
}}
"@
if (-not [MouseMove]::SetCursorPos({}, {})) {{ exit 4 }}
"#,
x,
y
);
let output = gui_powershell(NAME, &script)?;
gui_action(NAME, "powershell", &output)
}
#[cfg(target_os = "linux")]
{
let mut cmd = crate::safety::child_command("xdotool");
cmd.args(["mousemove", &x.to_string(), &y.to_string()]);
let output = bounded_cmd_output(NAME, "xdotool", cmd, TOOL_DEADLINE_SECS)?;
gui_action(NAME, "xdotool", &output)
}
#[cfg(target_os = "macos")]
{
let output = gui_macos_input(NAME, "move", &[x.to_string(), y.to_string()])?;
gui_action(NAME, "osascript", &output)
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
Err(crate::safety::unimplemented(
NAME,
"desktop automation is not implemented on this platform; NOTHING WAS DONE",
"run this on Windows, Linux or macOS",
))
}
}
#[allow(unused_variables)]
fn bi_gui_mouse_click(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "gui_mouse_click";
refuse_piped_input(NAME, &input)?;
let button = gui_button(NAME, &args, 0)?;
#[cfg(target_os = "windows")]
{
let (down, up) = gui_mouse_codes(button);
let script = crate::ps_script!(
r#"
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class MouseClick {{
[DllImport("user32.dll")] public static extern void mouse_event(uint dwFlags, int dx, int dy, uint dwData, UIntPtr dwExtraInfo);
}}
"@
[MouseClick]::mouse_event({}, 0, 0, 0, [UIntPtr]::Zero)
[MouseClick]::mouse_event({}, 0, 0, 0, [UIntPtr]::Zero)
"#,
down,
up
);
let output = gui_powershell(NAME, &script)?;
gui_action(NAME, "powershell", &output)
}
#[cfg(target_os = "linux")]
{
let btn = match button {
"right" => "3",
"middle" => "2",
_ => "1",
};
let mut cmd = crate::safety::child_command("xdotool");
cmd.args(["click", btn]);
let output = bounded_cmd_output(NAME, "xdotool", cmd, TOOL_DEADLINE_SECS)?;
gui_action(NAME, "xdotool", &output)
}
#[cfg(target_os = "macos")]
{
let output = gui_macos_input(NAME, "click", &[button.to_string()])?;
gui_action(NAME, "osascript", &output)
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
Err(crate::safety::unimplemented(
NAME,
"desktop automation is not implemented on this platform; NOTHING WAS DONE",
"run this on Windows, Linux or macOS",
))
}
}
fn bi_gui_mouse_double_click(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped_input("gui_mouse_double_click", &input)?;
// Each click validates the button again, so a bad button is refused before
// the first click is sent.
gui_button("gui_mouse_double_click", &args, 0)?;
bi_gui_mouse_click(args.clone(), None)?;
std::thread::sleep(std::time::Duration::from_millis(50));
bi_gui_mouse_click(args, None)
}
#[allow(unused_variables)]
fn bi_gui_mouse_drag(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "gui_mouse_drag";
refuse_piped_input(NAME, &input)?;
let x1 = gui_coord(NAME, "x1", gui_required(NAME, &args, 0, "an Int x1")?)?;
let y1 = gui_coord(NAME, "y1", gui_required(NAME, &args, 1, "an Int y1")?)?;
let x2 = gui_coord(NAME, "x2", gui_required(NAME, &args, 2, "an Int x2")?)?;
let y2 = gui_coord(NAME, "y2", gui_required(NAME, &args, 3, "an Int y2")?)?;
#[cfg(target_os = "windows")]
{
let script = crate::ps_script!(
r#"
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class MouseDrag {{
[DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y);
[DllImport("user32.dll")] public static extern void mouse_event(uint dwFlags, int dx, int dy, uint dwData, UIntPtr dwExtraInfo);
}}
"@
if (-not [MouseDrag]::SetCursorPos({}, {})) {{ exit 4 }}
[MouseDrag]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero)
if (-not [MouseDrag]::SetCursorPos({}, {})) {{ exit 4 }}
[MouseDrag]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero)
"#,
x1,
y1,
x2,
y2
);
let output = gui_powershell(NAME, &script)?;
gui_action(NAME, "powershell", &output)
}
#[cfg(target_os = "linux")]
{
let mut cmd = crate::safety::child_command("xdotool");
cmd.args([
"mousemove",
&x1.to_string(),
&y1.to_string(),
"mousedown",
"1",
"mousemove",
&x2.to_string(),
&y2.to_string(),
"mouseup",
"1",
]);
let output = bounded_cmd_output(NAME, "xdotool", cmd, TOOL_DEADLINE_SECS)?;
gui_action(NAME, "xdotool", &output)
}
#[cfg(target_os = "macos")]
{
let output = gui_macos_input(
NAME,
"drag",
&[
x1.to_string(),
y1.to_string(),
x2.to_string(),
y2.to_string(),
],
)?;
gui_action(NAME, "osascript", &output)
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
Err(crate::safety::unimplemented(
NAME,
"desktop automation is not implemented on this platform; NOTHING WAS DONE",
"run this on Windows, Linux or macOS",
))
}
}
#[allow(unused_variables)]
fn bi_gui_mouse_scroll(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "gui_mouse_scroll";
refuse_piped_input(NAME, &input)?;
// Notches: positive scrolls up, negative down. Bounded, so the wheel delta
// below cannot overflow.
let amount = match args.first() {
None => 3,
Some(Value::Int(n)) if (-100..=100).contains(n) => *n,
Some(other) => {
return Err(crate::safety::bad_arg(
NAME,
"amount: Int in -100..=100 (default 3)",
&other.to_display_string(),
))
}
};
#[cfg(target_os = "windows")]
{
// One notch is WHEEL_DELTA (120); a negative delta is its two's complement.
let wheel = (amount * 120) as i32 as u32;
let script = crate::ps_script!(
r#"
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class MouseScroll {{
[DllImport("user32.dll")] public static extern void mouse_event(uint dwFlags, int dx, int dy, uint dwData, UIntPtr dwExtraInfo);
}}
"@
[MouseScroll]::mouse_event(0x0800, 0, 0, {}, [UIntPtr]::Zero)
"#,
wheel
);
let output = gui_powershell(NAME, &script)?;
gui_action(NAME, "powershell", &output)
}
#[cfg(target_os = "linux")]
{
let dir = if amount > 0 { "4" } else { "5" };
let mut cmd = crate::safety::child_command("xdotool");
cmd.args(["click", "--repeat", &amount.unsigned_abs().to_string(), dir]);
let output = bounded_cmd_output(NAME, "xdotool", cmd, TOOL_DEADLINE_SECS)?;
gui_action(NAME, "xdotool", &output)
}
#[cfg(target_os = "macos")]
{
let output = gui_macos_input(NAME, "scroll", &[amount.to_string()])?;
gui_action(NAME, "osascript", &output)
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
Err(crate::safety::unimplemented(
NAME,
"desktop automation is not implemented on this platform; NOTHING WAS DONE",
"run this on Windows, Linux or macOS",
))
}
}
fn bi_gui_mouse_position(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "gui_mouse_position";
no_args_or_input(NAME, &args, &input)?;
#[cfg(target_os = "windows")]
{
let script = r#"
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class MousePos {
[DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT lpPoint);
public struct POINT { public int X; public int Y; }
}
"@
$point = New-Object MousePos+POINT
if (-not [MousePos]::GetCursorPos([ref]$point)) { exit 4 }
@{x=$point.X;y=$point.Y} | ConvertTo-Json
"#;
let output = gui_powershell(NAME, script)?;
gui_json_answer(NAME, "powershell", &output)
}
#[cfg(target_os = "linux")]
{
let mut cmd = crate::safety::child_command("xdotool");
cmd.arg("getmouselocation");
let output = bounded_cmd_output(NAME, "xdotool", cmd, TOOL_DEADLINE_SECS)?;
if !output.status.success() {
return Err(crate::safety::tool_failed(
NAME,
"xdotool",
output.status.code(),
&gui_stderr_tail(&output),
));
}
// "x:123 y:456 screen:0 window:789"
let text = String::from_utf8_lossy(&output.stdout).into_owned();
let mut rec = std::collections::BTreeMap::new();
for part in text.split_whitespace() {
if let Some((k, v)) = part.split_once(':') {
if k == "x" || k == "y" {
let n: i64 = v.parse().map_err(|_| {
crate::safety::tool_failed(
NAME,
"xdotool",
Some(0),
&format!("its answer had no usable {k}: {}", text.trim()),
)
})?;
rec.insert(k.to_string(), Value::Int(n));
}
}
}
if rec.len() != 2 {
return Err(crate::safety::tool_failed(
NAME,
"xdotool",
Some(0),
&format!("its answer had no x and y: {}", text.trim()),
));
}
Ok(Value::Record(rec))
}
#[cfg(target_os = "macos")]
{
let output = gui_macos_input(NAME, "pos", &[])?;
gui_json_answer(NAME, "osascript", &output)
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
Err(crate::safety::unimplemented(
NAME,
"reading the pointer is implemented on Windows, Linux and macOS; NOTHING WAS READ",
"run this on Windows, Linux or macOS",
))
}
}
#[allow(unused_variables)]
fn bi_gui_key_press(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "gui_key_press";
refuse_piped_input(NAME, &input)?;
let spec = gui_text(
NAME,
"key",
gui_required(NAME, &args, 0, "a key name")?,
GUI_KEY_MAX_BYTES,
false,
)?;
let key = parse_gui_key(NAME, &spec)?;
#[cfg(target_os = "windows")]
{
let keys = gui_sendkeys(&key);
let script = crate::ps_script!(
r#"
Add-Type -AssemblyName System.Windows.Forms
[System.Windows.Forms.SendKeys]::SendWait({})
"#,
crate::safety::ps_quote(&keys)
);
let output = gui_powershell(NAME, &script)?;
gui_action(NAME, "powershell", &output)
}
#[cfg(target_os = "linux")]
{
let mut cmd = crate::safety::child_command("xdotool");
cmd.args(["key", &gui_xdotool_key(&key)]);
let output = bounded_cmd_output(NAME, "xdotool", cmd, TOOL_DEADLINE_SECS)?;
gui_action(NAME, "xdotool", &output)
}
#[cfg(target_os = "macos")]
{
let code = macos_key_code(NAME, &key.key)?;
let flags = macos_key_flags(&key.mods);
let output = gui_macos_input(NAME, "key", &[code.to_string(), flags.to_string()])?;
gui_action(NAME, "osascript", &output)
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
Err(crate::safety::unimplemented(
NAME,
"desktop automation is not implemented on this platform; NOTHING WAS DONE",
"run this on Windows, Linux or macOS",
))
}
}
#[allow(unused_variables)]
fn bi_gui_key_combo(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "gui_key_combo";
refuse_piped_input(NAME, &input)?;
// The keys join into one spec: ("ctrl", "shift", "t") is ctrl+shift+t.
let mut parts = Vec::with_capacity(args.len());
for (i, arg) in args.iter().enumerate() {
parts.push(gui_text(
NAME,
&format!("key {}", i + 1),
arg,
GUI_KEY_MAX_BYTES,
false,
)?);
}
if parts.is_empty() {
return Err(crate::safety::bad_arg(NAME, "at least one key", "nothing"));
}
let key = parse_gui_key(NAME, &parts.join("+"))?;
#[cfg(target_os = "windows")]
{
let keys = gui_sendkeys(&key);
let script = crate::ps_script!(
r#"
Add-Type -AssemblyName System.Windows.Forms
[System.Windows.Forms.SendKeys]::SendWait({})
"#,
crate::safety::ps_quote(&keys)
);
let output = gui_powershell(NAME, &script)?;
gui_action(NAME, "powershell", &output)
}
#[cfg(target_os = "linux")]
{
let mut cmd = crate::safety::child_command("xdotool");
cmd.args(["key", &gui_xdotool_key(&key)]);
let output = bounded_cmd_output(NAME, "xdotool", cmd, TOOL_DEADLINE_SECS)?;
gui_action(NAME, "xdotool", &output)
}
#[cfg(target_os = "macos")]
{
let code = macos_key_code(NAME, &key.key)?;
let flags = macos_key_flags(&key.mods);
let output = gui_macos_input(NAME, "combo", &[code.to_string(), flags.to_string()])?;
gui_action(NAME, "osascript", &output)
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
Err(crate::safety::unimplemented(
NAME,
"desktop automation is not implemented on this platform; NOTHING WAS DONE",
"run this on Windows, Linux or macOS",
))
}
}
#[allow(unused_variables)]
fn bi_gui_type_text(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "gui_type_text";
refuse_piped_input(NAME, &input)?;
let text = gui_text(
NAME,
"text",
gui_required(NAME, &args, 0, "the text to type")?,
GUI_TYPE_MAX_BYTES,
false,
)?;
#[cfg(target_os = "windows")]
{
let keys = gui_sendkeys_text(&text);
let script = crate::ps_script!(
r#"
Add-Type -AssemblyName System.Windows.Forms
[System.Windows.Forms.SendKeys]::SendWait({})
"#,
crate::safety::ps_quote(&keys)
);
let output = gui_powershell(NAME, &script)?;
gui_action(NAME, "powershell", &output)
}
#[cfg(target_os = "linux")]
{
let mut cmd = crate::safety::child_command("xdotool");
cmd.args(["type", "--", &text]);
let output = bounded_cmd_output(NAME, "xdotool", cmd, TOOL_DEADLINE_SECS)?;
gui_action(NAME, "xdotool", &output)
}
#[cfg(target_os = "macos")]
{
let output = gui_macos_input(NAME, "type", &[text])?;
gui_action(NAME, "osascript", &output)
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
Err(crate::safety::unimplemented(
NAME,
"desktop automation is not implemented on this platform; NOTHING WAS DONE",
"run this on Windows, Linux or macOS",
))
}
}
fn bi_gui_get_active_window(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "gui_get_active_window";
no_args_or_input(NAME, &args, &input)?;
#[cfg(target_os = "windows")]
{
let script = r#"
Add-Type @"
using System;
using System.Runtime.InteropServices;
using System.Text;
public class ActiveWindow {
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
}
"@
$hwnd = [ActiveWindow]::GetForegroundWindow()
$title = New-Object System.Text.StringBuilder 256
[ActiveWindow]::GetWindowText($hwnd, $title, 256) | Out-Null
@{hwnd=$hwnd.ToInt64();title=$title.ToString()} | ConvertTo-Json
"#;
let output = gui_powershell(NAME, script)?;
gui_json_answer(NAME, "powershell", &output)
}
#[cfg(target_os = "linux")]
{
let mut cmd = crate::safety::child_command("xdotool");
cmd.args(["getactivewindow", "getwindowname"]);
let output = bounded_cmd_output(NAME, "xdotool", cmd, TOOL_DEADLINE_SECS)?;
if !output.status.success() {
return Err(crate::safety::tool_failed(
NAME,
"xdotool",
output.status.code(),
&gui_stderr_tail(&output),
));
}
let title = String::from_utf8_lossy(&output.stdout).trim().to_string();
let mut rec = std::collections::BTreeMap::new();
rec.insert("title".to_string(), Value::Str(title));
Ok(Value::Record(rec))
}
#[cfg(target_os = "macos")]
{
let output = gui_macos_input(NAME, "active", &[])?;
gui_json_answer(NAME, "osascript", &output)
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
Err(crate::safety::unimplemented(
NAME,
"reading the active window is implemented on Windows, Linux and macOS; NOTHING WAS READ",
"run this on Windows, Linux or macOS",
))
}
}
fn bi_gui_screen_size(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "gui_screen_size";
no_args_or_input(NAME, &args, &input)?;
#[cfg(target_os = "windows")]
{
let script = r#"
Add-Type -AssemblyName System.Windows.Forms
$screen = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
@{width=$screen.Width;height=$screen.Height} | ConvertTo-Json
"#;
let output = gui_powershell(NAME, script)?;
gui_json_answer(NAME, "powershell", &output)
}
#[cfg(target_os = "linux")]
{
let cmd = crate::safety::child_command("xdpyinfo");
let output = bounded_cmd_output(NAME, "xdpyinfo", cmd, TOOL_DEADLINE_SECS)?;
if !output.status.success() {
return Err(crate::safety::tool_failed(
NAME,
"xdpyinfo",
output.status.code(),
&gui_stderr_tail(&output),
));
}
let text = String::from_utf8_lossy(&output.stdout).into_owned();
// " dimensions: 1920x1080 pixels (508x285 millimeters)"
for line in text.lines() {
if line.contains("dimensions:") {
if let Some((w, h)) = line
.split_whitespace()
.nth(1)
.and_then(|dims| dims.split_once('x'))
{
if let (Ok(w), Ok(h)) = (w.parse::<i64>(), h.parse::<i64>()) {
let mut rec = std::collections::BTreeMap::new();
rec.insert("width".to_string(), Value::Int(w));
rec.insert("height".to_string(), Value::Int(h));
return Ok(Value::Record(rec));
}
}
}
}
Err(crate::safety::tool_failed(
NAME,
"xdpyinfo",
Some(0),
"its answer had no dimensions line",
))
}
#[cfg(target_os = "macos")]
{
let output = gui_macos_input(NAME, "screen", &[])?;
gui_json_answer(NAME, "osascript", &output)
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
Err(crate::safety::unimplemented(
NAME,
"reading the screen size is implemented on Windows, Linux and macOS; NOTHING WAS READ",
"run this on Windows, Linux or macOS",
))
}
}
/// Options accepted by `gui_find_image`, after validation.
#[cfg(feature = "native")]
struct FindImageOptions {
tolerance: u8,
all: bool,
limit: usize,
}
#[cfg(feature = "native")]
fn find_image_options(value: Option<&Value>) -> Result<FindImageOptions> {
let mut opts = FindImageOptions {
tolerance: 0,
all: false,
limit: 100,
};
let Some(value) = value else {
return Ok(opts);
};
let Value::Record(map) = value else {
return Err(crate::safety::bad_arg(
"gui_find_image",
"options: Record",
value.type_name(),
));
};
for (key, value) in map {
match (key.as_str(), value) {
("tolerance", Value::Int(n)) if (0..=255i64).contains(n) => opts.tolerance = *n as u8,
("all", Value::Bool(b)) => opts.all = *b,
("limit", Value::Int(n)) if (1..=1000i64).contains(n) => opts.limit = *n as usize,
("tolerance", _) | ("all", _) | ("limit", _) => {
return Err(crate::safety::arg_err(format!(
"gui_find_image: option {key} has the wrong value; \
tolerance is an Int in 0..=255, all a Bool, limit an Int in 1..=1000"
)))
}
_ => {
return Err(crate::safety::arg_err(format!(
"gui_find_image: unknown option {key}"
)))
}
}
}
Ok(opts)
}
/// Resolve one image path through the same read guards `cat` uses, then read
/// only its header. Returns the validated path and its (width, height).
#[cfg(feature = "native")]
fn find_image_header(path: &str) -> Result<(std::path::PathBuf, u32, u32)> {
if !std::path::Path::new(path).exists() {
return Err(crate::safety::not_found("gui_find_image", "file", path));
}
let validated = validate_read_path(path)?;
let meta = fs::metadata(&validated)
.map_err(|e| crate::safety::fs_error("gui_find_image", &validated, &e))?;
if !meta.is_file() {
return Err(crate::safety::arg_err(format!(
"gui_find_image: `{path}` is not a regular file"
)));
}
let reader = image::io::Reader::open(&validated)
.and_then(|r| r.with_guessed_format())
.map_err(|e| crate::safety::fs_error("gui_find_image", &validated, &e))?;
let (w, h) = reader.into_dimensions().map_err(|e| {
crate::safety::arg_err(format!(
"gui_find_image: `{path}` is not a PNG, JPEG, GIF or WebP image that can be read ({e})"
))
})?;
Ok((validated, w, h))
}
/// Decode one already-validated image to RGBA8.
#[cfg(feature = "native")]
fn find_image_decode(path: &std::path::Path, shown: &str) -> Result<image::RgbaImage> {
let reader = image::io::Reader::open(path)
.and_then(|r| r.with_guessed_format())
.map_err(|e| crate::safety::fs_error("gui_find_image", path, &e))?;
let img = reader.decode().map_err(|e| {
crate::safety::arg_err(format!("gui_find_image: `{shown}` failed to decode ({e})"))
})?;
Ok(img.to_rgba8())
}
/// `gui_find_image(haystack, needle, options?)`: template matching over image
/// files. Pure computation over pixels; it reads the two files and writes
/// nothing. See `signature.rs` for the contract.
#[cfg(feature = "native")]
fn bi_gui_find_image(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
use image_match::{self as im, Frame, MatchError};
const BUILTIN: &str = "gui_find_image";
if input.is_some() {
return Err(crate::safety::bad_arg(
BUILTIN,
"a direct call; pipeline input is not accepted",
"pipeline input",
));
}
if args.len() < 2 || args.len() > 3 {
return Err(crate::safety::arg_err(format!(
"gui_find_image takes a haystack, a needle and optional options; got {} arguments",
args.len()
)));
}
let mut paths = Vec::with_capacity(2);
for arg in &args[..2] {
match arg {
Value::Str(s) => paths.push(s.clone()),
other => {
return Err(crate::safety::bad_arg(
BUILTIN,
"a String path to an image file",
other.type_name(),
))
}
}
}
crate::safety::reject_option_like(BUILTIN, &paths)?;
let opts = find_image_options(args.get(2))?;
// Headers first: every refusal below happens before any pixel is decoded.
let (hay_path, hw, hh) = find_image_header(&paths[0])?;
let (needle_path, nw, nh) = find_image_header(&paths[1])?;
for (shown, w, h) in [(&paths[0], hw, hh), (&paths[1], nw, nh)] {
let pixels = u64::from(w) * u64::from(h);
if pixels > im::MAX_PIXELS {
return Err(crate::safety::budget_exceeded(
BUILTIN,
format!(
"`{shown}` is {w}x{h} = {pixels} pixels, above the {} pixel limit; not decoded",
im::MAX_PIXELS
),
"use a smaller image",
));
}
}
match im::plan(hw, hh, nw, nh) {
Ok(_) => {}
Err(MatchError::NeedleTooLarge { haystack, needle }) => {
return Err(crate::safety::arg_err(format!(
"gui_find_image: needle {}x{} does not fit in haystack {}x{}",
needle.0, needle.1, haystack.0, haystack.1
)))
}
Err(MatchError::WorkExceeded { work }) => {
let shown = work.map_or("more than 2^64".to_string(), |w| w.to_string());
return Err(crate::safety::budget_exceeded(
BUILTIN,
format!(
"worst case {shown} pixel comparisons exceeds the {} limit",
im::MAX_WORK
),
"use a smaller needle or haystack, or a smaller search area",
));
}
Err(_) => {
return Err(crate::safety::arg_err(
"gui_find_image: an image has a zero dimension",
))
}
}
let hay = find_image_decode(&hay_path, &paths[0])?;
let needle = find_image_decode(&needle_path, &paths[1])?;
let hits = im::find_matches(
Frame {
width: hay.width(),
height: hay.height(),
rgba: hay.as_raw(),
},
Frame {
width: needle.width(),
height: needle.height(),
rgba: needle.as_raw(),
},
opts.tolerance,
opts.all,
opts.limit,
)
.map_err(|e| crate::safety::arg_err(format!("gui_find_image: match refused ({e:?})")))?;
let record = |h: &im::Hit| {
let mut rec = BTreeMap::new();
rec.insert("x".to_string(), Value::Int(i64::from(h.x)));
rec.insert("y".to_string(), Value::Int(i64::from(h.y)));
rec.insert("width".to_string(), Value::Int(i64::from(nw)));
rec.insert("height".to_string(), Value::Int(i64::from(nh)));
Value::Record(rec)
};
if opts.all {
Ok(Value::Array(hits.iter().map(record).collect()))
} else {
Ok(hits.first().map_or(Value::Null, record))
}
}
#[cfg(not(feature = "native"))]
fn bi_gui_find_image(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// Without the `image` crate there is no decoder, so no answer is given.
Err(crate::safety::unimplemented(
"gui_find_image",
"image decoding needs the `native` feature; NOTHING WAS DONE",
"build with the default `native` feature",
))
}
fn bi_gui_ocr(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "gui_ocr";
refuse_piped_input(NAME, &input)?;
let image_path = gui_text(
NAME,
"image path",
gui_required(NAME, &args, 0, "an image path")?,
GUI_FIELD_MAX_BYTES,
false,
)?;
crate::safety::reject_option_like(NAME, std::slice::from_ref(&image_path))?;
// A read is guarded like any other read: inside the workspace in agent mode.
let image_path = guard_local_read(NAME, &image_path)?;
let mut cmd = crate::safety::child_command("tesseract");
cmd.args([&image_path, "stdout"]);
let output = bounded_cmd_output(NAME, "tesseract", cmd, TOOL_DEADLINE_SECS)?;
if output.status.success() {
Ok(Value::Str(
String::from_utf8_lossy(&output.stdout).into_owned(),
))
} else {
Err(crate::safety::tool_failed(
NAME,
"tesseract",
output.status.code(),
&gui_stderr_tail(&output),
))
}
}
#[allow(unused_variables)]
fn bi_gui_notify(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "gui_notify";
refuse_piped_input(NAME, &input)?;
let title = match args.first() {
None => "Notification".to_string(),
Some(v) => gui_text(NAME, "title", v, GUI_FIELD_MAX_BYTES, false)?,
};
let message = match args.get(1) {
None => String::new(),
Some(v) => gui_text(NAME, "message", v, GUI_FIELD_MAX_BYTES, true)?,
};
#[cfg(target_os = "windows")]
{
let script = crate::ps_script!(
r#"
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
$template = [Windows.UI.Notifications.ToastTemplateType]::ToastText02
$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent($template)
$text = $xml.GetElementsByTagName("text")
$text[0].AppendChild($xml.CreateTextNode({})) | Out-Null
$text[1].AppendChild($xml.CreateTextNode({})) | Out-Null
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("IronShell").Show($toast)
"#,
crate::safety::ps_quote(&title),
crate::safety::ps_quote(&message)
);
let output = gui_powershell(NAME, &script)?;
gui_action(NAME, "powershell", &output)
}
#[cfg(target_os = "linux")]
{
// `--` ends notify-send's options, so a title or message that begins
// with a dash is text.
let mut cmd = crate::safety::child_command("notify-send");
cmd.args(["--", &title, &message]);
let output = bounded_cmd_output(NAME, "notify-send", cmd, TOOL_DEADLINE_SECS)?;
gui_action(NAME, "notify-send", &output)
}
#[cfg(target_os = "macos")]
{
let mut cmd = crate::safety::child_command("osascript");
cmd.args([
"-e",
&crate::applescript!(
"display notification {} with title {}",
crate::safety::applescript_quote(&message),
crate::safety::applescript_quote(&title)
),
]);
let output = bounded_cmd_output(NAME, "osascript", cmd, TOOL_DEADLINE_SECS)?;
gui_action(NAME, "osascript", &output)
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
Err(crate::safety::unimplemented(
NAME,
"notifications are not implemented on this platform; NOTHING WAS SHOWN",
"run this on Windows, Linux or macOS",
))
}
}
#[allow(unused_variables)]
fn bi_gui_dialog_message(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "gui_dialog_message";
refuse_piped_input(NAME, &input)?;
let message = gui_text(
NAME,
"message",
gui_required(NAME, &args, 0, "the message to show")?,
GUI_FIELD_MAX_BYTES,
false,
)?;
let title = match args.get(1) {
None => "Message".to_string(),
Some(v) => gui_text(NAME, "title", v, GUI_FIELD_MAX_BYTES, false)?,
};
crate::safety::refuse_if_headless(NAME)?;
#[cfg(target_os = "windows")]
{
let script = crate::ps_script!(
r#"
Add-Type -AssemblyName System.Windows.Forms
[System.Windows.Forms.MessageBox]::Show({}, {})
"#,
crate::safety::ps_quote(&message),
crate::safety::ps_quote(&title)
);
let output = gui_powershell(NAME, &script)?;
gui_action(NAME, "powershell", &output)
}
#[cfg(target_os = "linux")]
{
let mut cmd = crate::safety::child_command("zenity");
cmd.args(["--info", "--title", &title, "--text", &message]);
let output = bounded_cmd_output(NAME, "zenity", cmd, GUI_DIALOG_DEADLINE_SECS)?;
gui_dialog_ack(NAME, "zenity", &output)
}
#[cfg(target_os = "macos")]
{
let mut cmd = crate::safety::child_command("osascript");
cmd.args([
"-e",
&crate::applescript!(
"display dialog {} with title {}",
crate::safety::applescript_quote(&message),
crate::safety::applescript_quote(&title)
),
]);
let output = bounded_cmd_output(NAME, "osascript", cmd, GUI_DIALOG_DEADLINE_SECS)?;
gui_dialog_ack(NAME, "osascript", &output)
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
Err(crate::safety::unimplemented(
NAME,
"dialogs are not implemented on this platform; NOTHING WAS SHOWN",
"run this on Windows, Linux or macOS",
))
}
}
#[allow(unused_variables)]
fn bi_gui_dialog_input(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "gui_dialog_input";
refuse_piped_input(NAME, &input)?;
let prompt = match args.first() {
None => "Enter value:".to_string(),
Some(v) => gui_text(NAME, "prompt", v, GUI_FIELD_MAX_BYTES, false)?,
};
let title = match args.get(1) {
None => "Input".to_string(),
Some(v) => gui_text(NAME, "title", v, GUI_FIELD_MAX_BYTES, false)?,
};
crate::safety::refuse_if_headless(NAME)?;
#[cfg(target_os = "windows")]
{
// The VisualBasic InputBox returns "" on cancel, so an empty answer is Null.
let script = crate::ps_script!(
r#"
Add-Type -AssemblyName Microsoft.VisualBasic
[Microsoft.VisualBasic.Interaction]::InputBox({}, {})
"#,
crate::safety::ps_quote(&prompt),
crate::safety::ps_quote(&title)
);
let output = gui_powershell(NAME, &script)?;
gui_dialog_answer(NAME, "powershell", None, &output)
}
#[cfg(target_os = "linux")]
{
let mut cmd = crate::safety::child_command("zenity");
cmd.args(["--entry", "--title", &title, "--text", &prompt]);
let output = bounded_cmd_output(NAME, "zenity", cmd, GUI_DIALOG_DEADLINE_SECS)?;
gui_dialog_answer(NAME, "zenity", Some(1), &output)
}
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
{
Err(crate::safety::unimplemented(
NAME,
"input dialogs are implemented on Windows and Linux; NOTHING WAS ASKED",
"use input_read_line on the terminal",
))
}
}
#[allow(unused_variables)]
fn bi_gui_dialog_file_open(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "gui_dialog_file_open";
refuse_piped_input(NAME, &input)?;
let title = match args.first() {
None => "Open File".to_string(),
Some(v) => gui_text(NAME, "title", v, GUI_FIELD_MAX_BYTES, false)?,
};
crate::safety::refuse_if_headless(NAME)?;
#[cfg(target_os = "windows")]
{
let script = crate::ps_script!(
r#"
Add-Type -AssemblyName System.Windows.Forms
$dialog = New-Object System.Windows.Forms.OpenFileDialog
$dialog.Title = {}
if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {{ $dialog.FileName }}
"#,
crate::safety::ps_quote(&title)
);
let output = gui_powershell(NAME, &script)?;
gui_dialog_answer(NAME, "powershell", None, &output)
}
#[cfg(target_os = "linux")]
{
let mut cmd = crate::safety::child_command("zenity");
cmd.args(["--file-selection", "--title", &title]);
let output = bounded_cmd_output(NAME, "zenity", cmd, GUI_DIALOG_DEADLINE_SECS)?;
gui_dialog_answer(NAME, "zenity", Some(1), &output)
}
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
{
Err(crate::safety::unimplemented(
NAME,
"file dialogs are implemented on Windows and Linux; NOTHING WAS SHOWN",
"pass the path as an argument instead",
))
}
}
#[allow(unused_variables)]
fn bi_gui_dialog_file_save(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "gui_dialog_file_save";
refuse_piped_input(NAME, &input)?;
let title = match args.first() {
None => "Save File".to_string(),
Some(v) => gui_text(NAME, "title", v, GUI_FIELD_MAX_BYTES, false)?,
};
crate::safety::refuse_if_headless(NAME)?;
#[cfg(target_os = "windows")]
{
let script = crate::ps_script!(
r#"
Add-Type -AssemblyName System.Windows.Forms
$dialog = New-Object System.Windows.Forms.SaveFileDialog
$dialog.Title = {}
if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {{ $dialog.FileName }}
"#,
crate::safety::ps_quote(&title)
);
let output = gui_powershell(NAME, &script)?;
gui_dialog_answer(NAME, "powershell", None, &output)
}
#[cfg(target_os = "linux")]
{
let mut cmd = crate::safety::child_command("zenity");
cmd.args(["--file-selection", "--save", "--title", &title]);
let output = bounded_cmd_output(NAME, "zenity", cmd, GUI_DIALOG_DEADLINE_SECS)?;
gui_dialog_answer(NAME, "zenity", Some(1), &output)
}
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
{
Err(crate::safety::unimplemented(
NAME,
"file dialogs are implemented on Windows and Linux; NOTHING WAS SHOWN",
"pass the path as an argument instead",
))
}
}
#[allow(unused_variables)]
fn bi_gui_dialog_folder(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "gui_dialog_folder";
refuse_piped_input(NAME, &input)?;
let title = match args.first() {
None => "Select Folder".to_string(),
Some(v) => gui_text(NAME, "title", v, GUI_FIELD_MAX_BYTES, false)?,
};
crate::safety::refuse_if_headless(NAME)?;
#[cfg(target_os = "windows")]
{
let script = crate::ps_script!(
r#"
Add-Type -AssemblyName System.Windows.Forms
$dialog = New-Object System.Windows.Forms.FolderBrowserDialog
$dialog.Description = {}
if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {{ $dialog.SelectedPath }}
"#,
crate::safety::ps_quote(&title)
);
let output = gui_powershell(NAME, &script)?;
gui_dialog_answer(NAME, "powershell", None, &output)
}
#[cfg(target_os = "linux")]
{
let mut cmd = crate::safety::child_command("zenity");
cmd.args(["--file-selection", "--directory", "--title", &title]);
let output = bounded_cmd_output(NAME, "zenity", cmd, GUI_DIALOG_DEADLINE_SECS)?;
gui_dialog_answer(NAME, "zenity", Some(1), &output)
}
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
{
Err(crate::safety::unimplemented(
NAME,
"folder dialogs are implemented on Windows and Linux; NOTHING WAS SHOWN",
"pass the path as an argument instead",
))
}
}
fn bi_gui_wait(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "gui_wait";
refuse_piped_input(NAME, &input)?;
// A negative Int became u64::MAX milliseconds: gui_wait(-1) slept forever.
let ms = match args.first() {
None => 1000,
Some(Value::Int(n)) if (0..=3_600_000).contains(n) => *n as u64,
Some(Value::Float(f)) if (0.0..=3_600_000.0).contains(f) => *f as u64,
Some(other) => {
return Err(crate::safety::bad_arg(
NAME,
"milliseconds from 0 to 3600000",
&other.to_display_string(),
))
}
};
std::thread::sleep(std::time::Duration::from_millis(ms));
Ok(Value::Bool(true))
}
fn bi_gui_color_picker(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "gui_color_picker";
no_args_or_input(NAME, &args, &input)?;
crate::safety::refuse_if_headless(NAME)?;
#[cfg(target_os = "windows")]
{
// Cancelled, the dialog prints nothing: Null.
let script = r#"
Add-Type -AssemblyName System.Windows.Forms
$dialog = New-Object System.Windows.Forms.ColorDialog
if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
@{r=$dialog.Color.R;g=$dialog.Color.G;b=$dialog.Color.B;hex=('#' + $dialog.Color.R.ToString('X2') + $dialog.Color.G.ToString('X2') + $dialog.Color.B.ToString('X2'))} | ConvertTo-Json
}
"#;
let output = gui_powershell(NAME, script)?;
if !output.status.success() {
return Err(crate::safety::tool_failed(
NAME,
"powershell",
output.status.code(),
&gui_stderr_tail(&output),
));
}
if String::from_utf8_lossy(&output.stdout).trim().is_empty() {
return Ok(Value::Null);
}
gui_json_answer(NAME, "powershell", &output)
}
#[cfg(target_os = "linux")]
{
let mut cmd = crate::safety::child_command("zenity");
cmd.arg("--color-selection");
let output = bounded_cmd_output(NAME, "zenity", cmd, GUI_DIALOG_DEADLINE_SECS)?;
if output.status.code() == Some(1) {
return Ok(Value::Null);
}
if !output.status.success() {
return Err(crate::safety::tool_failed(
NAME,
"zenity",
output.status.code(),
&gui_stderr_tail(&output),
));
}
// zenity answers "rgb(255,0,0)".
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
let parsed: Option<(i64, i64, i64)> = text
.strip_prefix("rgb(")
.and_then(|t| t.strip_suffix(')'))
.and_then(|t| {
let mut it = t.split(',').map(|p| p.trim().parse::<i64>());
match (it.next(), it.next(), it.next(), it.next()) {
(Some(Ok(r)), Some(Ok(g)), Some(Ok(b)), None) => Some((r, g, b)),
_ => None,
}
});
let Some((r, g, b)) = parsed else {
return Err(crate::safety::tool_failed(
NAME,
"zenity",
Some(0),
&format!("its answer was not rgb(r,g,b): {text}"),
));
};
let mut rec = std::collections::BTreeMap::new();
rec.insert("r".to_string(), Value::Int(r));
rec.insert("g".to_string(), Value::Int(g));
rec.insert("b".to_string(), Value::Int(b));
rec.insert(
"hex".to_string(),
Value::Str(format!("#{r:02X}{g:02X}{b:02X}")),
);
Ok(Value::Record(rec))
}
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
{
Err(crate::safety::unimplemented(
NAME,
"the colour picker is implemented on Windows and Linux; NOTHING WAS SHOWN",
"run this on Windows or Linux",
))
}
}
// ============================================================================
// 430-459: Web Automation
// ============================================================================
/// Largest response body a `web_*` builtin buffers or returns. A longer body is
/// `E_BUDGET_EXCEEDED`: the caller gets an error, never a prefix.
const WEB_BODY_MAX: usize = 8 * 1024 * 1024;
/// Largest file `web_download` will write.
const WEB_DOWNLOAD_MAX: u64 = 512 * 1024 * 1024;
/// Most elements `web_scrape` returns for one selector.
const WEB_SCRAPE_MAX_MATCHES: usize = 1000;
/// Wall-clock limit for one request, handed to curl.
const WEB_MAX_TIME_SECS: &str = "30";
/// Redirects followed before a request is refused. Other request paths that
/// follow redirects by hand share this ceiling.
pub(crate) const WEB_MAX_REDIRS: usize = 10;
/// Where curl's `-w` report starts in a hop's stdout: the status and the
/// `Location` it did not follow. Plain ASCII, so it survives any argv encoding.
const WEB_HOP_MARK: &str = "\nISH-HOP-7c41 ";
/// Trailing bytes of curl's stderr kept for an error message.
const WEB_STDERR_TAIL: usize = 4 * 1024;
const WEB_URL_EXPECTED: &str = "an absolute http or https URL such as https://example.com";
/// Refuse piped input for a `web_*` builtin, which takes none.
fn web_no_input(builtin: &str, input: &Option<Value>) -> Result<()> {
match input {
None => Ok(()),
Some(v) => Err(crate::safety::bad_arg(
builtin,
"no piped input; pass the arguments directly",
v.type_name(),
)),
}
}
/// A string argument at `idx`, or `E_BAD_ARG` naming what was expected.
fn web_text_arg<'a>(
builtin: &str,
args: &'a [Value],
idx: usize,
expected: &str,
) -> Result<&'a str> {
match args.get(idx) {
Some(Value::Str(s)) | Some(Value::Uri(s)) => Ok(s.as_str()),
other => Err(crate::safety::bad_arg(
builtin,
expected,
other.map(|v| v.type_name()).unwrap_or("nothing"),
)),
}
}
/// Parse an absolute `http` or `https` URL and return its normalised text.
///
/// Refused before any effect: control characters (a newline ends a shell
/// command and an HTTP header), every other scheme (`file:` would make curl
/// read the local disk outside the file guards, and `ftp:`, `gopher:` and
/// `dict:` reach protocols nothing here audits), and text `url` cannot parse.
/// The normalised text is what gets passed to curl, so both agree on the target.
fn web_http_url(builtin: &str, text: &str) -> Result<String> {
// A leading `-` is an option to curl, which can read a config file.
crate::safety::reject_option_like(builtin, &[text.to_string()])?;
if text.chars().any(|c| c.is_control()) {
return Err(crate::safety::bad_arg(
builtin,
WEB_URL_EXPECTED,
"text with a control character",
));
}
let parsed = url::Url::parse(text).map_err(|e| {
crate::safety::bad_arg(builtin, WEB_URL_EXPECTED, &format!("{text:?} ({e})"))
})?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(crate::safety::bad_arg(
builtin,
WEB_URL_EXPECTED,
&format!("a {} URL", parsed.scheme()),
));
}
Ok(parsed.to_string())
}
/// A coded `E_TOOL_FAILED` for a web request that could not be completed.
fn web_failure(builtin: &str, message: String, hint: &str) -> anyhow::Error {
anyhow::Error::new(crate::safety::SafetyError {
code: crate::safety::ErrorCode::ToolFailed,
message: format!("{builtin}: {message}"),
builtin: builtin.to_string(),
hint: hint.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
})
}
/// What one curl run produced.
struct WebRun {
status: std::process::ExitStatus,
/// stdout, up to `WEB_BODY_MAX` bytes.
body: Vec<u8>,
/// stdout was longer than `WEB_BODY_MAX`.
over: bool,
/// The last `WEB_STDERR_TAIL` bytes of stderr.
stderr: Vec<u8>,
}
/// How long past curl's own `--max-time` the shell waits before it kills
/// curl's process tree itself: curl's timer does not cover a stalled DNS
/// resolver thread or a curl that never starts its transfer.
const WEB_KILL_GRACE_SECS: u64 = 15;
/// Run curl with `args` as an argv, never through a shell. The caller's last
/// argument is the URL.
///
/// A caller that passes `-L` gets redirects followed, but not by curl: curl
/// would follow them past the egress allowlist and the SSRF check, which saw
/// only the first URL. Each hop runs without `-L`, curl reports the status and
/// the `Location` it did not follow, and [`crate::safety::guard_redirect`]
/// judges the next URL before it is requested. With `-I`, every hop's headers
/// are returned, as curl's own `-L -I` printed them.
fn web_curl(builtin: &str, args: &[&str]) -> Result<WebRun> {
if !args.iter().any(|a| matches!(*a, "-L" | "--location")) {
return web_curl_once(builtin, args);
}
let heads = args.iter().any(|a| matches!(*a, "-I" | "--head"));
let mut argv: Vec<String> = args
.iter()
.filter(|a| !matches!(**a, "-L" | "--location"))
.map(|a| a.to_string())
.collect();
// curl keeps only the last `-w`, so a caller's own format (web_check_url
// prints the status) goes in front of the hop report rather than lost.
let mut caller_format = String::new();
if let Some(i) = argv.iter().position(|a| a == "-w") {
if i + 1 < argv.len() - 1 {
caller_format = argv.remove(i + 1);
argv.remove(i);
}
}
let report = format!("{caller_format}{WEB_HOP_MARK}%{{response_code}} %{{redirect_url}}");
let at = argv.len() - 1;
argv.splice(at..at, ["-w".to_string(), report]);
let mut earlier = Vec::new();
for _ in 0..=WEB_MAX_REDIRS {
let url = argv.last().cloned().unwrap_or_default();
let refs: Vec<&str> = argv.iter().map(String::as_str).collect();
let mut run = web_curl_once(builtin, &refs)?;
let next = web_take_hop(&mut run.body);
if heads {
earlier.append(&mut run.body);
}
let next = match next {
Some((code, next)) if (300..400).contains(&code) && !next.is_empty() => next,
_ => {
if heads {
run.body = earlier;
}
return Ok(run);
}
};
if !run.status.success() || run.over {
return Ok(run);
}
crate::safety::guard_redirect(builtin, &url, &next)?;
let next = web_http_url(builtin, &next)?;
*argv.last_mut().expect("the URL is the last argument") = next;
}
Err(crate::safety::budget_exceeded(
builtin,
format!("more than {WEB_MAX_REDIRS} redirects"),
"request the final URL directly",
))
}
/// Remove curl's `-w` report from the end of a hop's stdout and return the
/// status and the redirect target it names. None when the report is missing,
/// as it is when the body was cut at the size cap.
fn web_take_hop(body: &mut Vec<u8>) -> Option<(u16, String)> {
let mark = WEB_HOP_MARK.as_bytes();
let at = body.windows(mark.len()).rposition(|w| w == mark)?;
let report = String::from_utf8_lossy(&body[at + mark.len()..]).into_owned();
body.truncate(at);
let (code, next) = report.split_once(' ').unwrap_or((report.as_str(), ""));
Some((code.trim().parse().ok()?, next.trim().to_string()))
}
/// One curl run, with no redirect followed.
///
/// Every run is limited to http and https and to a wall-clock limit. curl's
/// `--max-time` is the first limit; [`run_tree`] enforces a second one,
/// killing curl's tree and abandoning its pipes, so the call cannot outlive
/// it. stdout is kept only up to `WEB_BODY_MAX`; a longer body is reported,
/// not truncated.
fn web_curl_once(builtin: &str, args: &[&str]) -> Result<WebRun> {
// The URL is the last argument. It is checked here, which every curl
// transfer goes through, after the approval gate and the budget. curl is
// told to connect to the address the check accepted, so a second DNS
// answer cannot send the transfer to another host.
let Some((url, rest)) = args.split_last() else {
return Err(crate::safety::arg_err(format!("{builtin}: no URL")));
};
let checked = crate::security::check_http_url(builtin, url)?;
let mut argv = vec![
"-sS",
"--proto",
"=http,https",
"--max-time",
WEB_MAX_TIME_SECS,
];
argv.extend_from_slice(rest);
let pin = checked.curl_resolve();
if let Some(pin) = pin.as_deref() {
argv.extend_from_slice(&["--resolve", pin]);
}
argv.push(*url);
let limit = WEB_MAX_TIME_SECS.parse::<u64>().unwrap_or(30) + WEB_KILL_GRACE_SECS;
let run = run_tree(
"curl",
&argv,
|_| {},
None,
std::time::Duration::from_secs(limit),
Keep::Head(WEB_BODY_MAX),
Keep::Tail(WEB_STDERR_TAIL),
)
.map_err(|e| match e {
TreeError::Spawn(e) => crate::safety::spawn_error(builtin, "curl", &e),
TreeError::Wait(e) => web_failure(
builtin,
format!("could not wait for curl: {e}"),
"retry the call",
),
})?;
let status = match run.status {
Some(status) if !run.timed_out => status,
_ => {
return Err(crate::safety::budget_exceeded(
builtin,
format!("curl did not finish within {limit} s and was stopped"),
"the server is slow or unreachable; retry later or request a smaller resource",
))
}
};
Ok(WebRun {
status,
body: run.stdout,
over: run.stdout_truncated,
stderr: run.stderr,
})
}
/// The body of a run that completed. A body over the cap is
/// `E_BUDGET_EXCEEDED`; a nonzero exit is `E_TOOL_FAILED` carrying curl's own
/// message, which for `--fail` names the HTTP status.
fn web_body(builtin: &str, run: WebRun) -> Result<Vec<u8>> {
if run.over {
return Err(crate::safety::budget_exceeded(
builtin,
format!("the response is longer than {WEB_BODY_MAX} bytes"),
"request a smaller resource; this shell returns no truncated body",
));
}
if !run.status.success() {
return Err(web_tool_failed(builtin, &run));
}
Ok(run.body)
}
fn web_tool_failed(builtin: &str, run: &WebRun) -> anyhow::Error {
crate::safety::tool_failed(
builtin,
"curl",
run.status.code(),
&String::from_utf8_lossy(&run.stderr),
)
}
fn web_text(body: Vec<u8>) -> Value {
Value::Str(String::from_utf8_lossy(&body).into_owned())
}
fn web_json_value(builtin: &str, body: &[u8]) -> Result<Value> {
serde_json::from_slice::<serde_json::Value>(body)
.map(json_to_value)
.map_err(|e| {
web_failure(
builtin,
format!("the response is not JSON: {e}"),
"use web_fetch for a response that is not JSON",
)
})
}
/// A request body as text: a String goes as it is, any other value as JSON.
fn web_request_body(builtin: &str, value: &Value) -> Result<String> {
match value {
Value::Str(s) => Ok(s.clone()),
other => serde_json::to_string(&value_to_json(other.clone())).map_err(|e| {
crate::safety::bad_arg(builtin, "a value that encodes as JSON", &e.to_string())
}),
}
}
/// GET `url` and return the body as text. The caller has validated the URL and
/// guarded it. An HTTP error status is a coded failure: an error page is not
/// the resource that was asked for.
fn web_get_body(builtin: &str, url: &str) -> Result<Value> {
// Every caller has already checked the URL; this keeps a later caller from
// handing curl an option instead.
crate::safety::reject_option_like(builtin, &[url.to_string()])?;
let run = web_curl(builtin, &["-L", "--fail", url])?;
Ok(web_text(web_body(builtin, run)?))
}
fn bi_web_open_url(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
web_no_input("web_open_url", &input)?;
let url = web_browser_target("web_open_url", &args)?;
guard_network("web_open_url", &url)?;
open_in_browser("web_open_url", &url)
}
fn bi_web_browser_open(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
web_no_input("web_browser_open", &input)?;
let url = web_browser_target("web_browser_open", &args)?;
open_in_browser("web_browser_open", &url)
}
/// The URL a browser builtin will hand to the desktop, checked before any
/// effect: an http or https URL, then the desktop-launcher checks, which refuse
/// option-like text and control characters.
fn web_browser_target(builtin: &str, args: &[Value]) -> Result<String> {
let url = web_http_url(builtin, web_text_arg(builtin, args, 0, WEB_URL_EXPECTED)?)?;
crate::safety::reject_unsafe_url(builtin, &url)?;
Ok(url)
}
/// Launch the desktop's web handler on an already-checked URL. A launcher that
/// starts is `true`; one that cannot start, or exits with an error, is a coded
/// failure rather than `false`.
fn open_in_browser(builtin: &str, url: &str) -> Result<Value> {
#[cfg(target_os = "windows")]
{
// Not `cmd /C start`: `cmd` splits its command line on `&`, which is the
// query-string separator, so no URL check can make that call safe.
// `rundll32 url.dll,FileProtocolHandler` is the same ShellExecute
// dispatch with no shell in front of it. Spawned, not waited on: a
// browser runs until the user closes it.
let mut browser = crate::safety::child_command("rundll32.exe");
crate::safety::strip_ironshell_env(&mut browser);
browser
.args(["url.dll,FileProtocolHandler", url])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| crate::safety::spawn_error(builtin, "rundll32.exe", &e))?;
return Ok(Value::Bool(true));
}
#[cfg(target_os = "macos")]
{
let mut cmd = crate::safety::child_command("open");
cmd.arg(url);
return launcher_result(builtin, "open", crate::safety::output_with_timeout(cmd, 30));
}
#[cfg(target_os = "linux")]
{
let mut cmd = crate::safety::child_command("xdg-open");
cmd.arg(url);
return launcher_result(
builtin,
"xdg-open",
crate::safety::output_with_timeout(cmd, 30),
);
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
{
return Err(crate::safety::unimplemented(
builtin,
"opening a browser is not implemented on this platform; NOTHING WAS DONE",
"open the URL with the system's browser",
));
}
}
/// The outcome of a desktop launcher that was run to completion.
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn launcher_result(
builtin: &str,
program: &str,
ran: std::io::Result<std::process::Output>,
) -> Result<Value> {
match ran {
Ok(out) if out.status.success() => Ok(Value::Bool(true)),
Ok(out) => Err(crate::safety::tool_failed(
builtin,
program,
out.status.code(),
&String::from_utf8_lossy(&out.stderr),
)),
Err(e) if e.kind() == std::io::ErrorKind::TimedOut => Err(web_failure(
builtin,
format!("`{program}` did not return within 30 s"),
"check the desktop's URL handler",
)),
Err(e) => Err(crate::safety::spawn_error(builtin, program, &e)),
}
}
fn bi_web_fetch(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
web_no_input("web_fetch", &input)?;
let url = web_http_url(
"web_fetch",
web_text_arg("web_fetch", &args, 0, WEB_URL_EXPECTED)?,
)?;
guard_network("web_fetch", &url)?;
web_get_body("web_fetch", &url)
}
// `web_get`, `web_browser_open`, `web_robots_txt` and `web_sitemap` are not in
// `safety::SELF_GUARDED`, so the dispatcher's central guard meters and gates
// them by name. Calling `guard_network` here as well would charge them twice.
fn bi_web_get(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
web_no_input("web_get", &input)?;
let url = web_http_url(
"web_get",
web_text_arg("web_get", &args, 0, WEB_URL_EXPECTED)?,
)?;
web_get_body("web_get", &url)
}
fn bi_web_post(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
web_no_input("web_post", &input)?;
let url = web_http_url(
"web_post",
web_text_arg("web_post", &args, 0, WEB_URL_EXPECTED)?,
)?;
let data = match args.get(1) {
Some(v) => web_request_body("web_post", v)?,
None => String::new(),
};
guard_network("web_post", &url)?;
// `--data-raw`, not `-d`: curl's `-d` reads a value beginning with `@` as a
// file name and sends that file's contents, which would be a local-file
// read leaving the machine.
let run = web_curl("web_post", &["-X", "POST", "--data-raw", &data, &url])?;
Ok(web_text(web_body("web_post", run)?))
}
fn bi_web_json_get(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
web_no_input("web_json_get", &input)?;
let url = web_http_url(
"web_json_get",
web_text_arg("web_json_get", &args, 0, WEB_URL_EXPECTED)?,
)?;
guard_network("web_json_get", &url)?;
let run = web_curl(
"web_json_get",
&["-L", "-H", "Accept: application/json", &url],
)?;
web_json_value("web_json_get", &web_body("web_json_get", run)?)
}
fn bi_web_json_post(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
web_no_input("web_json_post", &input)?;
let url = web_http_url(
"web_json_post",
web_text_arg("web_json_post", &args, 0, WEB_URL_EXPECTED)?,
)?;
let data = match args.get(1) {
Some(v) => serde_json::to_string(&value_to_json(v.clone())).map_err(|e| {
crate::safety::bad_arg(
"web_json_post",
"a value that encodes as JSON",
&e.to_string(),
)
})?,
None => "{}".to_string(),
};
guard_network("web_json_post", &url)?;
let run = web_curl(
"web_json_post",
&[
"-X",
"POST",
"-H",
"Content-Type: application/json",
"-H",
"Accept: application/json",
"--data-raw",
&data,
&url,
],
)?;
web_json_value("web_json_post", &web_body("web_json_post", run)?)
}
/// A selector `web_scrape` can honour: a bare tag name. Class and id selectors
/// (`.x`, `#x`) and combinators are refused, because the earlier code treated
/// `.x` as the tag `x` and answered with the wrong elements.
fn web_tag_selector(selector: &str) -> Result<String> {
let mut chars = selector.chars();
let starts_with_letter = chars.next().is_some_and(|c| c.is_ascii_alphabetic());
if !starts_with_letter || !selector.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err(crate::safety::bad_arg(
"web_scrape",
"a tag name such as \"h1\" or \"a\"; class (.x), id (#x) and combined selectors are not supported",
selector,
));
}
Ok(selector.to_ascii_lowercase())
}
/// The index of the next `<tag` opening at or after `from`, where the tag name
/// really ends (so `<a` does not match `<abbr`).
fn web_next_open(lower: &str, from: usize, open: &str) -> Option<usize> {
let mut at = from;
while let Some(rel) = lower[at..].find(open) {
let idx = at + rel;
if matches!(
lower.as_bytes().get(idx + open.len()).copied(),
Some(b'>' | b' ' | b'\t' | b'\n' | b'\r' | b'/')
) {
return Some(idx);
}
at = idx + 1;
}
None
}
/// Every element named `tag`, from its opening tag to its matching closing
/// tag, nesting of the same name included. The search is over ASCII lowercase,
/// which keeps byte offsets identical to the original text.
fn web_scrape_elements(builtin: &str, html: &str, tag: &str) -> Result<Vec<Value>> {
let lower = html.to_ascii_lowercase();
let open = format!("<{tag}");
let close = format!("</{tag}>");
let mut found = Vec::new();
let mut from = 0;
while let Some(start) = web_next_open(&lower, from, &open) {
let mut depth = 1usize;
let mut cursor = start + open.len();
let end = loop {
let Some(rel) = lower[cursor..].find(&close) else {
break None; // never closed: nothing after this opening can close it either
};
let close_at = cursor + rel;
match web_next_open(&lower, cursor, &open) {
Some(open_at) if open_at < close_at => {
depth += 1;
cursor = open_at + open.len();
}
_ => {
depth -= 1;
cursor = close_at + close.len();
if depth == 0 {
break Some(cursor);
}
}
}
};
match end {
Some(end) => {
if found.len() == WEB_SCRAPE_MAX_MATCHES {
return Err(crate::safety::budget_exceeded(
builtin,
format!("more than {WEB_SCRAPE_MAX_MATCHES} <{tag}> elements"),
"scrape a narrower page; this shell returns no partial list",
));
}
found.push(Value::Str(html[start..end].to_string()));
from = end;
}
None => break,
}
}
Ok(found)
}
fn bi_web_scrape(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
web_no_input("web_scrape", &input)?;
let url = web_http_url(
"web_scrape",
web_text_arg("web_scrape", &args, 0, WEB_URL_EXPECTED)?,
)?;
let selector = match args.get(1) {
None => None,
Some(Value::Str(s)) => Some(web_tag_selector(s)?),
Some(other) => {
return Err(crate::safety::bad_arg(
"web_scrape",
"selector: String",
other.type_name(),
))
}
};
guard_network("web_scrape", &url)?;
let run = web_curl("web_scrape", &["-L", "--fail", &url])?;
let html = String::from_utf8_lossy(&web_body("web_scrape", run)?).into_owned();
match selector {
None => Ok(Value::Str(html)),
Some(tag) => Ok(Value::Array(web_scrape_elements(
"web_scrape",
&html,
&tag,
)?)),
}
}
/// The file name `web_download` uses when none is given: the last non-empty
/// path segment of the URL, or `download`.
fn web_download_name(url: &str) -> String {
url::Url::parse(url)
.ok()
.and_then(|u| {
u.path_segments()
.and_then(|mut segments| segments.rfind(|s| !s.is_empty()).map(str::to_string))
})
.filter(|name| name != "." && name != "..")
.unwrap_or_else(|| "download".to_string())
}
fn bi_web_download(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
web_no_input("web_download", &input)?;
let url = web_http_url(
"web_download",
web_text_arg("web_download", &args, 0, WEB_URL_EXPECTED)?,
)?;
let path = match args.get(1) {
None => web_download_name(&url),
Some(Value::Str(s)) if !s.is_empty() => s.clone(),
Some(Value::Str(_)) => {
return Err(crate::safety::bad_arg(
"web_download",
"a nonempty file path",
"\"\"",
))
}
Some(other) => {
return Err(crate::safety::bad_arg(
"web_download",
"path: String",
other.type_name(),
))
}
};
guard_network("web_download", &url)?;
// A download writes a local file as well as fetching, so the path goes
// through the same write jail `file_write` uses.
let path = guard_local_write("web_download", &path)?;
// curl wrote straight into the destination, so a dropped connection left a
// truncated file where the old one had been, and a transaction could not
// restore it. The download now lands in a temporary file beside the
// destination (beside a symlink's target, as write_file_atomically does)
// and is renamed over it only once curl succeeds.
let requested = std::path::Path::new(&path);
let target = match fs::symlink_metadata(requested) {
Ok(meta) if meta.file_type().is_symlink() => fs::canonicalize(requested)
.map_err(|e| crate::safety::fs_error("web_download", &path, &e))?,
_ => requested.to_path_buf(),
};
let parent = target
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| std::path::Path::new("."));
let temporary = parent.join(format!(".ironshell_dl_{}", uuid::Uuid::new_v4()));
let temporary_arg = temporary.to_string_lossy().to_string();
let max = WEB_DOWNLOAD_MAX.to_string();
let run = web_curl(
"web_download",
&[
"-L",
"--fail",
"--max-filesize",
&max,
"-o",
&temporary_arg,
&url,
],
);
let run = match run {
Ok(run) => run,
Err(e) => {
let _ = fs::remove_file(&temporary);
return Err(e);
}
};
// curl's exit status 63 is "maximum file size exceeded".
if run.status.code() == Some(63) {
let _ = fs::remove_file(&temporary);
return Err(crate::safety::budget_exceeded(
"web_download",
format!("the file is larger than {WEB_DOWNLOAD_MAX} bytes"),
"download a smaller file",
));
}
if !run.status.success() {
let _ = fs::remove_file(&temporary);
return Err(web_tool_failed("web_download", &run));
}
crate::tx::snapshot(&path);
if let Err(e) = fs::rename(&temporary, &target) {
let _ = fs::remove_file(&temporary);
return Err(crate::safety::fs_error("web_download", &path, &e));
}
Ok(Value::Bool(true))
}
fn bi_web_parse_url(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let url = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"web_parse_url",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let mut rec = std::collections::BTreeMap::new();
// Simple URL parsing
let mut remaining = url.as_str();
// Protocol
if let Some(idx) = remaining.find("://") {
rec.insert(
"protocol".to_string(),
Value::Str(remaining[..idx].to_string()),
);
remaining = &remaining[idx + 3..];
}
// Host and port
let host_end = remaining.find('/').unwrap_or(remaining.len());
let host_part = &remaining[..host_end];
if let Some(port_idx) = host_part.find(':') {
rec.insert(
"host".to_string(),
Value::Str(host_part[..port_idx].to_string()),
);
rec.insert(
"port".to_string(),
Value::Str(host_part[port_idx + 1..].to_string()),
);
} else {
rec.insert("host".to_string(), Value::Str(host_part.to_string()));
}
remaining = &remaining[host_end..];
// Path and query
if let Some(query_idx) = remaining.find('?') {
rec.insert(
"path".to_string(),
Value::Str(remaining[..query_idx].to_string()),
);
let query = &remaining[query_idx + 1..];
if let Some(hash_idx) = query.find('#') {
rec.insert(
"query".to_string(),
Value::Str(query[..hash_idx].to_string()),
);
rec.insert(
"hash".to_string(),
Value::Str(query[hash_idx + 1..].to_string()),
);
} else {
rec.insert("query".to_string(), Value::Str(query.to_string()));
}
} else if let Some(hash_idx) = remaining.find('#') {
rec.insert(
"path".to_string(),
Value::Str(remaining[..hash_idx].to_string()),
);
rec.insert(
"hash".to_string(),
Value::Str(remaining[hash_idx + 1..].to_string()),
);
} else {
rec.insert("path".to_string(), Value::Str(remaining.to_string()));
}
Ok(Value::Record(rec))
}
fn bi_web_encode_url(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let text = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => return Ok(Value::Str("".to_string())),
};
let mut encoded = String::new();
for c in text.chars() {
match c {
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '~' => encoded.push(c),
_ => {
for b in c.to_string().bytes() {
encoded.push_str(&format!("%{:02X}", b));
}
}
}
}
Ok(Value::Str(encoded))
}
fn bi_web_decode_url(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let text = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => return Ok(Value::Str("".to_string())),
};
let bytes = text.as_bytes();
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' {
if index + 2 >= bytes.len()
|| !bytes[index + 1].is_ascii_hexdigit()
|| !bytes[index + 2].is_ascii_hexdigit()
{
return Err(crate::safety::bad_arg(
"web_decode_url",
"two hexadecimal digits after each percent sign",
&text,
));
}
index += 3;
} else {
index += 1;
}
}
let form_text = text.replace('+', " ");
let decoded = urlencoding::decode(&form_text).map_err(|error| {
crate::safety::bad_arg(
"web_decode_url",
"percent-encoded UTF-8",
&error.to_string(),
)
})?;
Ok(Value::Str(decoded.into_owned()))
}
fn bi_web_parse_query(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let query = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => return Ok(Value::Record(std::collections::BTreeMap::new())),
};
let mut rec = std::collections::BTreeMap::new();
for pair in query.trim_start_matches('?').split('&') {
if let Some((k, v)) = pair.split_once('=') {
rec.insert(k.to_string(), Value::Str(v.to_string()));
}
}
Ok(Value::Record(rec))
}
fn bi_web_build_query(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let rec = match args.first() {
Some(Value::Record(r)) => r.clone(),
_ => return Ok(Value::Str("".to_string())),
};
let pairs: Vec<String> = rec
.iter()
.map(|(k, v)| {
format!(
"{}={}",
k,
match v {
Value::Str(s) => s.clone(),
v => format!("{:?}", v),
}
)
})
.collect();
Ok(Value::Str(pairs.join("&")))
}
/// The header record of the final response in a `curl -I -L` transcript. Each
/// redirect hop prints its own block starting with `HTTP/`, so the record is
/// reset at each one and only the block that answered is kept.
fn web_final_headers(text: &str) -> std::collections::BTreeMap<String, Value> {
let mut rec = std::collections::BTreeMap::new();
for line in text.lines() {
let line = line.trim_end_matches('\r');
if line.starts_with("HTTP/") {
rec.clear();
} else if let Some((k, v)) = line.split_once(':') {
rec.insert(k.trim().to_lowercase(), Value::Str(v.trim().to_string()));
}
}
rec
}
fn bi_web_headers(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
web_no_input("web_headers", &input)?;
let url = web_http_url(
"web_headers",
web_text_arg("web_headers", &args, 0, WEB_URL_EXPECTED)?,
)?;
guard_network("web_headers", &url)?;
let run = web_curl("web_headers", &["-I", "-L", &url])?;
let text = String::from_utf8_lossy(&web_body("web_headers", run)?).into_owned();
Ok(Value::Record(web_final_headers(&text)))
}
fn bi_web_cookies(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
web_no_input("web_cookies", &input)?;
let url = web_http_url(
"web_cookies",
web_text_arg("web_cookies", &args, 0, WEB_URL_EXPECTED)?,
)?;
guard_network("web_cookies", &url)?;
let run = web_curl("web_cookies", &["-I", "-L", &url])?;
let text = String::from_utf8_lossy(&web_body("web_cookies", run)?).into_owned();
// Every Set-Cookie line across the redirect hops, as the server sent it.
let cookies = text
.lines()
.filter_map(|line| {
let (name, value) = line.trim_end_matches('\r').split_once(':')?;
name.trim()
.eq_ignore_ascii_case("set-cookie")
.then(|| Value::Str(value.trim().to_string()))
})
.collect();
Ok(Value::Array(cookies))
}
/// Percent-encode text for a form: unreserved characters pass, every other
/// byte becomes `%XX`.
fn web_percent_encode(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for b in text.bytes() {
match b {
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
/// A form record as `application/x-www-form-urlencoded` text, keys in sorted
/// order. Values are encoded: a value containing `&` or `=` used to split into
/// extra fields, and a non-text value used to be sent as its Debug form.
fn web_form_encode(fields: &std::collections::BTreeMap<String, Value>) -> Result<String> {
let mut pairs = Vec::with_capacity(fields.len());
for (key, value) in fields {
let text = match value {
Value::Str(s) => s.clone(),
Value::Int(n) => n.to_string(),
Value::Float(f) => f.to_string(),
Value::Bool(b) => b.to_string(),
other => {
return Err(crate::safety::bad_arg(
"web_form_submit",
&format!("field {key}: String, Int, Float or Bool"),
other.type_name(),
))
}
};
pairs.push(format!(
"{}={}",
web_percent_encode(key),
web_percent_encode(&text)
));
}
Ok(pairs.join("&"))
}
fn bi_web_form_submit(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
web_no_input("web_form_submit", &input)?;
let url = web_http_url(
"web_form_submit",
web_text_arg("web_form_submit", &args, 0, WEB_URL_EXPECTED)?,
)?;
let body = match args.get(1) {
None => String::new(),
// Already encoded, sent as it is.
Some(Value::Str(s)) => s.clone(),
Some(Value::Record(fields)) => web_form_encode(fields)?,
Some(other) => {
return Err(crate::safety::bad_arg(
"web_form_submit",
"fields: Record, or an already-encoded String",
other.type_name(),
))
}
};
guard_network("web_form_submit", &url)?;
let run = web_curl(
"web_form_submit",
&["-X", "POST", "--data-raw", &body, &url],
)?;
Ok(web_text(web_body("web_form_submit", run)?))
}
/// A form field name curl will take unquoted.
fn web_field_name_ok(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
}
fn bi_web_upload_file(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
web_no_input("web_upload_file", &input)?;
let url = web_http_url(
"web_upload_file",
web_text_arg("web_upload_file", &args, 0, WEB_URL_EXPECTED)?,
)?;
let path = web_text_arg("web_upload_file", &args, 1, "a local file path")?;
// curl reads `;` and `"` inside `-F name=@path` as option separators, so a
// path containing them would send under a different name or type.
if path.is_empty() || path.contains(';') || path.contains('"') {
return Err(crate::safety::bad_arg(
"web_upload_file",
"a nonempty file path without ';' or '\"' characters",
path,
));
}
let field = match args.get(2) {
None => "file".to_string(),
Some(Value::Str(s)) if web_field_name_ok(s) => s.clone(),
Some(Value::Str(s)) => {
return Err(crate::safety::bad_arg(
"web_upload_file",
"a form field name of letters, digits, '_', '-' or '.'",
s,
))
}
Some(other) => {
return Err(crate::safety::bad_arg(
"web_upload_file",
"field: String",
other.type_name(),
))
}
};
guard_network("web_upload_file", &url)?;
// The upload reads a local file, so it goes through the read guard that
// `file_read` uses. The read is unjailed by design, as it is for `file_read`.
let local = guard_local_read("web_upload_file", path)?;
let form = format!("{field}=@{local}");
let run = web_curl(
"web_upload_file",
&["-X", "POST", "--fail", "-F", &form, &url],
)?;
web_body("web_upload_file", run)?;
Ok(Value::Bool(true))
}
/// Methods `web_rest_api` will send.
const WEB_METHODS: &[&str] = &["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
/// An HTTP header name: RFC 9110 token characters, so a name cannot contain a
/// space, a colon, a control character, or a leading `@` that curl would read
/// as a file of headers.
fn web_header_name_ok(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || "!#$%&'*+-.^_`|~".contains(c))
}
fn bi_web_rest_api(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
web_no_input("web_rest_api", &input)?;
let method = match args.first() {
Some(Value::Str(s)) => s.to_ascii_uppercase(),
other => {
return Err(crate::safety::bad_arg(
"web_rest_api",
"method: String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
if !WEB_METHODS.contains(&method.as_str()) {
return Err(crate::safety::bad_arg(
"web_rest_api",
&format!("method: one of {}", WEB_METHODS.join(", ")),
&method,
));
}
let url = web_http_url(
"web_rest_api",
web_text_arg("web_rest_api", &args, 1, WEB_URL_EXPECTED)?,
)?;
let body = match args.get(2) {
None => None,
Some(v) => Some(
serde_json::to_string(&value_to_json(v.clone())).map_err(|e| {
crate::safety::bad_arg(
"web_rest_api",
"a value that encodes as JSON",
&e.to_string(),
)
})?,
),
};
// Headers are checked in full before any request is made. A value with a
// CR or LF would start a new header line, so it is refused, not sent.
let mut headers: Vec<String> = Vec::new();
let mut has_content_type = false;
match args.get(3) {
None => {}
Some(Value::Record(map)) => {
for (name, value) in map {
if !web_header_name_ok(name) {
return Err(crate::safety::bad_arg(
"web_rest_api",
"a header name of letters, digits and !#$%&'*+-.^_`|~",
name,
));
}
let Value::Str(val) = value else {
return Err(crate::safety::bad_arg(
"web_rest_api",
&format!("header {name}: String"),
value.type_name(),
));
};
if val.chars().any(|c| c.is_control()) {
return Err(crate::safety::bad_arg(
"web_rest_api",
&format!("header {name}: a value without control characters"),
"a value containing a CR, LF or other control character",
));
}
if name.eq_ignore_ascii_case("content-type") {
has_content_type = true;
}
headers.push(format!("{name}: {val}"));
}
}
Some(other) => {
return Err(crate::safety::bad_arg(
"web_rest_api",
"headers: Record",
other.type_name(),
))
}
}
guard_network("web_rest_api", &url)?;
let mut argv: Vec<&str> = vec!["-X", method.as_str()];
for header in &headers {
argv.push("-H");
argv.push(header.as_str());
}
if let Some(data) = body.as_deref() {
if !has_content_type {
argv.extend(["-H", "Content-Type: application/json"]);
}
argv.extend(["--data-raw", data]);
}
argv.push(url.as_str());
let run = web_curl("web_rest_api", &argv)?;
let response = web_body("web_rest_api", run)?;
match serde_json::from_slice::<serde_json::Value>(&response) {
Ok(json) => Ok(json_to_value(json)),
Err(_) => Ok(web_text(response)),
}
}
/// Most messages one `web_websocket` exchange may set as `max_messages`.
const WS_MAX_MESSAGES: i64 = 1000;
/// Messages collected when `max_messages` is not given.
const WS_DEFAULT_MESSAGES: usize = 100;
/// Longest `timeout_secs` one exchange may ask for, and its default.
const WS_MAX_TIMEOUT_SECS: i64 = 120;
const WS_DEFAULT_TIMEOUT_SECS: u64 = 10;
/// Most payload, over all received messages, one exchange keeps.
const WS_PAYLOAD_MAX: usize = 8 * 1024 * 1024;
const WS_URL_EXPECTED: &str = "an absolute ws or wss URL such as wss://example.com/socket";
/// What one `web_websocket` call was asked to do, validated before anything
/// is opened.
struct WsPlan {
url: String,
send: Vec<String>,
max_messages: usize,
timeout: std::time::Duration,
}
fn ws_plan(args: &[Value]) -> Result<WsPlan> {
const NAME: &str = "web_websocket";
if args.len() > 3 {
return Err(crate::safety::bad_arg(
NAME,
"at most three arguments: url, send, opts",
&format!("{} arguments", args.len()),
));
}
let text = web_text_arg(NAME, args, 0, WS_URL_EXPECTED)?;
if text.chars().any(|c| c.is_control()) {
return Err(crate::safety::bad_arg(
NAME,
WS_URL_EXPECTED,
"text with a control character",
));
}
let parsed = url::Url::parse(text)
.map_err(|e| crate::safety::bad_arg(NAME, WS_URL_EXPECTED, &format!("{text:?} ({e})")))?;
if !matches!(parsed.scheme(), "ws" | "wss") || parsed.host_str().is_none() {
return Err(crate::safety::bad_arg(NAME, WS_URL_EXPECTED, text));
}
let send = match args.get(1) {
None | Some(Value::Null) => Vec::new(),
Some(Value::Str(s)) => vec![s.clone()],
Some(Value::Array(items)) => items
.iter()
.map(|v| match v {
Value::Str(s) => Ok(s.clone()),
other => Err(crate::safety::bad_arg(
NAME,
"send: a String or an Array of Strings",
&format!("an Array holding {}", other.type_name()),
)),
})
.collect::<Result<Vec<_>>>()?,
Some(other) => {
return Err(crate::safety::bad_arg(
NAME,
"send: a String, an Array of Strings, or null",
other.type_name(),
))
}
};
let mut max_messages = WS_DEFAULT_MESSAGES;
let mut timeout_secs = WS_DEFAULT_TIMEOUT_SECS;
match args.get(2) {
None | Some(Value::Null) => {}
Some(Value::Record(map)) => {
for (key, value) in map {
match (key.as_str(), value) {
("max_messages", Value::Int(n)) if (1..=WS_MAX_MESSAGES).contains(n) => {
max_messages = *n as usize
}
("timeout_secs", Value::Int(n)) if (1..=WS_MAX_TIMEOUT_SECS).contains(n) => {
timeout_secs = *n as u64
}
("max_messages", v) | ("timeout_secs", v) => {
return Err(crate::safety::bad_arg(
NAME,
&format!(
"{key}: max_messages is an Int in 1..={WS_MAX_MESSAGES}, \
timeout_secs an Int in 1..={WS_MAX_TIMEOUT_SECS}"
),
v.type_name(),
))
}
_ => {
return Err(crate::safety::bad_arg(
NAME,
"opts keys max_messages and timeout_secs",
&format!("unknown option {key:?}"),
))
}
}
}
}
Some(other) => {
return Err(crate::safety::bad_arg(
NAME,
"opts: a Record or null",
other.type_name(),
))
}
}
Ok(WsPlan {
url: parsed.to_string(),
send,
max_messages,
timeout: std::time::Duration::from_secs(timeout_secs),
})
}
/// `web_websocket(url, send?, opts?)`: one bounded exchange. A shell call
/// cannot hold a socket open between calls, so this connects, sends `send` as
/// text frames, collects messages until the server closes, `max_messages`
/// arrive or `timeout_secs` pass, then closes. The deadline covers the whole
/// exchange, connect and handshake included, through socket timeouts set to
/// the time that remains; reaching it while collecting is an answer
/// (`timed_out`), not an error.
#[cfg(feature = "native")]
fn bi_web_websocket(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
use tungstenite::Message;
const NAME: &str = "web_websocket";
web_no_input(NAME, &input)?;
let plan = ws_plan(&args)?;
let started = std::time::Instant::now();
let remaining = || plan.timeout.saturating_sub(started.elapsed());
let failed = |what: &str, e: &dyn std::fmt::Display| {
web_failure(
NAME,
format!("{what} {}: {e}", plan.url),
"check the URL and that the server accepts WebSocket connections",
)
};
let too_slow = |what: &str| {
crate::safety::budget_exceeded(
NAME,
format!("{what} {} within {:?}", plan.url, plan.timeout),
"the server is slow or unreachable; retry later or raise timeout_secs",
)
};
let parsed = url::Url::parse(&plan.url).map_err(|e| failed("could not parse", &e))?;
let host = parsed.host_str().unwrap_or_default().to_string();
let port = parsed.port_or_known_default().unwrap_or(80);
// The same address check as the HTTP builtins, under the HTTP name of the
// scheme, and the connection goes to the addresses it accepted.
let mut as_http = parsed.clone();
as_http
.set_scheme(if parsed.scheme() == "wss" {
"https"
} else {
"http"
})
.map_err(|()| failed("could not check", &"the URL's scheme"))?;
let checked = crate::security::check_http_url(NAME, as_http.as_str())?;
let addrs: Vec<std::net::SocketAddr> = match checked.pin() {
Some((_, pinned)) => pinned,
None => std::net::ToSocketAddrs::to_socket_addrs(&(
host.trim_start_matches('[').trim_end_matches(']'),
port,
))
.map_err(|e| failed("could not resolve", &e))?
.collect(),
};
let mut last_err: Option<std::io::Error> = None;
let mut tcp = None;
for addr in addrs {
let left = remaining();
if left.is_zero() {
break;
}
match std::net::TcpStream::connect_timeout(&addr, left) {
Ok(s) => {
tcp = Some(s);
break;
}
Err(e) => last_err = Some(e),
}
}
let Some(tcp) = tcp else {
if remaining().is_zero() {
return Err(too_slow("could not connect to"));
}
let e = last_err.map_or_else(|| "no address".to_string(), |e| e.to_string());
return Err(failed("could not connect to", &e));
};
// The deadline is set on the socket itself, before the handshake and then
// through the WebSocket's own stream. A `try_clone` would not do: on
// Windows a duplicated handle's timeouts do not bound reads on the
// original, so a silent server held the call for its own timeout.
let set_deadline = |tcp: &std::net::TcpStream, left: std::time::Duration| -> Result<()> {
let left = left.max(std::time::Duration::from_millis(1));
tcp.set_read_timeout(Some(left))
.and_then(|()| tcp.set_write_timeout(Some(left)))
.map_err(|e| failed("could not set a deadline on", &e))
};
set_deadline(&tcp, remaining())?;
let mut config = tungstenite::protocol::WebSocketConfig::default();
config.max_message_size = Some(WS_PAYLOAD_MAX);
config.max_frame_size = Some(WS_PAYLOAD_MAX);
let is_timeout = |e: &tungstenite::Error| {
matches!(e, tungstenite::Error::Io(io)
if matches!(io.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut))
};
crate::tls::install_provider();
let (mut socket, _response) =
tungstenite::client_tls_with_config(plan.url.as_str(), tcp, Some(config), None).map_err(
|e| match e {
tungstenite::HandshakeError::Failure(e) if is_timeout(&e) => {
too_slow("could not finish the handshake with")
}
tungstenite::HandshakeError::Failure(e) => failed("the handshake failed with", &e),
tungstenite::HandshakeError::Interrupted(_) => {
too_slow("could not finish the handshake with")
}
},
)?;
type WsSocket =
tungstenite::WebSocket<tungstenite::stream::MaybeTlsStream<std::net::TcpStream>>;
let deadline_socket = |socket: &WsSocket, left: std::time::Duration| -> Result<()> {
match socket.get_ref() {
tungstenite::stream::MaybeTlsStream::Plain(tcp) => set_deadline(tcp, left),
tungstenite::stream::MaybeTlsStream::Rustls(tls) => set_deadline(&tls.sock, left),
_ => Err(failed(
"could not set a deadline on",
&"an unexpected stream type",
)),
}
};
for text in &plan.send {
deadline_socket(&socket, remaining())?;
socket
.send(Message::text(text.clone()))
.map_err(|e| failed("could not send to", &e))?;
}
let mut messages = Vec::new();
let mut received = 0usize;
let mut closed_by_server = false;
let mut timed_out = false;
while messages.len() < plan.max_messages {
let left = remaining();
if left.is_zero() {
timed_out = true;
break;
}
deadline_socket(&socket, left)?;
let message = match socket.read() {
Ok(m) => m,
Err(tungstenite::Error::ConnectionClosed | tungstenite::Error::AlreadyClosed) => {
closed_by_server = true;
break;
}
Err(tungstenite::Error::Capacity(e)) => {
return Err(crate::safety::budget_exceeded(
NAME,
format!("a message from {} is larger than allowed: {e}", plan.url),
"this shell keeps at most 8 MiB per exchange; ask the server for less",
))
}
Err(e) if is_timeout(&e) => {
timed_out = true;
break;
}
Err(e) => return Err(failed("could not read from", &e)),
};
let size = match &message {
Message::Text(t) => t.len(),
Message::Binary(b) => b.len(),
_ => 0,
};
received = received.saturating_add(size);
if received > WS_PAYLOAD_MAX {
return Err(crate::safety::budget_exceeded(
NAME,
format!("{} sent more than {WS_PAYLOAD_MAX} bytes", plan.url),
"this shell keeps at most 8 MiB per exchange and returns no truncated answer; lower max_messages",
));
}
match message {
Message::Text(t) => messages.push(Value::Str(t.to_string())),
Message::Binary(b) => {
use base64::Engine;
let mut rec = BTreeMap::new();
rec.insert(
"binary".to_string(),
Value::Str(base64::engine::general_purpose::STANDARD.encode(&b)),
);
messages.push(Value::Record(rec));
}
Message::Close(_) => {
closed_by_server = true;
break;
}
Message::Ping(_) | Message::Pong(_) | Message::Frame(_) => {}
}
}
if !closed_by_server {
// A polite close, bounded by a short deadline of its own: the answer
// is already collected, so a failure here changes nothing.
let _ = deadline_socket(&socket, std::time::Duration::from_millis(500));
let _ = socket.close(None);
let _ = socket.flush();
}
let mut out = BTreeMap::new();
out.insert("messages".to_string(), Value::Array(messages));
out.insert(
"closed_by_server".to_string(),
Value::Bool(closed_by_server),
);
out.insert("timed_out".to_string(), Value::Bool(timed_out));
Ok(Value::Record(out))
}
#[cfg(not(feature = "native"))]
fn bi_web_websocket(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
web_no_input("web_websocket", &input)?;
ws_plan(&args)?;
Err(crate::safety::unimplemented(
"web_websocket",
"WebSocket support needs the `native` feature; NOTHING WAS OPENED",
"build with the default `native` feature",
))
}
fn bi_web_graphql(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
web_no_input("web_graphql", &input)?;
let url = web_http_url(
"web_graphql",
web_text_arg("web_graphql", &args, 0, WEB_URL_EXPECTED)?,
)?;
let query = web_text_arg("web_graphql", &args, 1, "query: String")?;
let variables = args
.get(2)
.map(|v| value_to_json(v.clone()))
.unwrap_or(serde_json::Value::Null);
let body = serde_json::json!({ "query": query, "variables": variables }).to_string();
guard_network("web_graphql", &url)?;
let run = web_curl(
"web_graphql",
&[
"-X",
"POST",
"-H",
"Content-Type: application/json",
"--data-raw",
&body,
&url,
],
)?;
web_json_value("web_graphql", &web_body("web_graphql", run)?)
}
fn bi_web_check_url(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
web_no_input("web_check_url", &input)?;
let url = web_http_url(
"web_check_url",
web_text_arg("web_check_url", &args, 0, WEB_URL_EXPECTED)?,
)?;
guard_network("web_check_url", &url)?;
// The body goes to the null device; stdout carries only the status code.
let sink = if cfg!(windows) { "NUL" } else { "/dev/null" };
let run = web_curl(
"web_check_url",
&["-L", "-o", sink, "-w", "%{http_code}", &url],
)?;
let text = String::from_utf8_lossy(&web_body("web_check_url", run)?).into_owned();
let code: u16 = text.trim().parse().map_err(|_| {
web_failure(
"web_check_url",
format!("curl printed no HTTP status code: {:?}", text.trim()),
"retry the call",
)
})?;
// A transport failure is an error above, so `false` means the server
// answered with an error status.
Ok(Value::Bool((200..400).contains(&code)))
}
/// The origin (`scheme://host[:port]`) of an absolute http or https URL. A bare
/// host name is refused: guessing a scheme for it would request a host the
/// caller did not name.
fn web_site_root(builtin: &str, args: &[Value]) -> Result<String> {
let text = web_text_arg(builtin, args, 0, WEB_URL_EXPECTED)?;
let normalised = web_http_url(builtin, text)?;
let parsed = url::Url::parse(&normalised)
.map_err(|e| crate::safety::bad_arg(builtin, WEB_URL_EXPECTED, &e.to_string()))?;
Ok(parsed.origin().ascii_serialization())
}
fn bi_web_robots_txt(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
web_no_input("web_robots_txt", &input)?;
let url = format!("{}/robots.txt", web_site_root("web_robots_txt", &args)?);
web_get_body("web_robots_txt", &url)
}
fn bi_web_sitemap(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
web_no_input("web_sitemap", &input)?;
let url = format!("{}/sitemap.xml", web_site_root("web_sitemap", &args)?);
web_get_body("web_sitemap", &url)
}
fn bi_web_extract_emails(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let text = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"web_extract_emails",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let mut emails = Vec::new();
let mut current = String::new();
let mut in_email = false;
for c in text.chars().chain(std::iter::once('\0')) {
if c.is_alphanumeric() || c == '.' || c == '_' || c == '-' || c == '+' {
current.push(c);
if c == '@' {
in_email = true;
}
} else if c == '@' {
current.push(c);
in_email = true;
} else {
if in_email && current.contains('@') && current.contains('.') {
let email = current.trim_matches('.').to_string();
if !emails.contains(&Value::Str(email.clone())) {
emails.push(Value::Str(email));
}
}
current.clear();
in_email = false;
}
}
Ok(Value::Array(emails))
}
fn bi_web_extract_phones(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let text = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"web_extract_phones",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let mut phones = Vec::new();
let mut current = String::new();
for c in text.chars().chain(std::iter::once('\0')) {
if c.is_ascii_digit() || c == '+' || c == '-' || c == '(' || c == ')' || c == ' ' {
current.push(c);
} else {
let digits: String = current.chars().filter(|c| c.is_ascii_digit()).collect();
if digits.len() >= 7 && digits.len() <= 15 {
phones.push(Value::Str(current.trim().to_string()));
}
current.clear();
}
}
Ok(Value::Array(phones))
}
fn bi_web_html_to_text(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let html = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => return Ok(Value::Str("".to_string())),
};
Ok(Value::Str(strip_html_tags(&html)))
}
fn bi_web_html_to_markdown(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let html = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => return Ok(Value::Str("".to_string())),
};
// Simple HTML to Markdown conversion
let mut md = html.clone();
md = md.replace("<h1>", "# ").replace("</h1>", "\n");
md = md.replace("<h2>", "## ").replace("</h2>", "\n");
md = md.replace("<h3>", "### ").replace("</h3>", "\n");
md = md.replace("<p>", "").replace("</p>", "\n\n");
md = md
.replace("<br>", "\n")
.replace("<br/>", "\n")
.replace("<br />", "\n");
md = md.replace("<strong>", "**").replace("</strong>", "**");
md = md.replace("<b>", "**").replace("</b>", "**");
md = md.replace("<em>", "*").replace("</em>", "*");
md = md.replace("<i>", "*").replace("</i>", "*");
md = md.replace("<code>", "`").replace("</code>", "`");
md = md.replace("<li>", "- ").replace("</li>", "\n");
md = md.replace("<ul>", "").replace("</ul>", "\n");
md = md.replace("<ol>", "").replace("</ol>", "\n");
// Strip remaining tags
md = strip_html_tags(&md);
Ok(Value::Str(md))
}
fn bi_web_json_path(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let json_val = match args.first() {
Some(v) => v.clone(),
_ => return Ok(Value::Null),
};
let path = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
_ => return Ok(json_val),
};
let mut current = json_val;
for part in path
.trim_start_matches('$')
.trim_start_matches('.')
.split('.')
{
let part = part.trim();
if part.is_empty() {
continue;
}
// Array index?
if part.contains('[') {
if let Some((name, idx_str)) = part.split_once('[') {
if !name.is_empty() {
current = match ¤t {
Value::Record(r) => r.get(name).cloned().unwrap_or(Value::Null),
_ => Value::Null,
};
}
let idx: usize = idx_str
.strip_suffix(']')
.and_then(|index| index.parse().ok())
.ok_or_else(|| {
crate::safety::bad_arg(
"web_json_path",
"a nonnegative array index followed by ]",
idx_str,
)
})?;
current = match ¤t {
Value::Array(arr) => arr.get(idx).cloned().unwrap_or(Value::Null),
_ => Value::Null,
};
}
} else {
current = match ¤t {
Value::Record(r) => r.get(part).cloned().unwrap_or(Value::Null),
_ => Value::Null,
};
}
}
Ok(current)
}
/// Query markup with a subset of XPath. Pure: the parser and evaluator live in
/// `src/web_xpath.rs`, and this wrapper only checks arguments and maps refusals.
fn bi_web_xpath(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
web_no_input("web_xpath", &input)?;
let html = web_text_arg("web_xpath", &args, 0, "html: String")?;
let expression = web_text_arg("web_xpath", &args, 1, "expression: String")?;
if args.len() > 2 {
return Err(crate::safety::bad_arg(
"web_xpath",
"two arguments: html and expression",
&format!("{} arguments", args.len()),
));
}
if html.len() > crate::web_xpath::MAX_HTML_BYTES {
return Err(crate::safety::budget_exceeded(
"web_xpath",
format!(
"html of {} bytes is over the {} MiB limit",
html.len(),
crate::web_xpath::MAX_HTML_BYTES / (1024 * 1024)
),
"pass the part of the page that holds the match",
));
}
use crate::web_xpath::Refusal;
match crate::web_xpath::evaluate(html, expression, WEB_SCRAPE_MAX_MATCHES) {
Ok(found) => Ok(Value::Array(found.into_iter().map(Value::Str).collect())),
Err(Refusal::Expression { got }) => Err(crate::safety::bad_arg(
"web_xpath",
"a supported XPath expression",
&got,
)),
Err(Refusal::Depth) => Err(crate::safety::budget_exceeded(
"web_xpath",
format!(
"element nesting deeper than {}",
crate::web_xpath::MAX_DEPTH
),
"the markup is nested too deeply to query; fix the source or extract a smaller part",
)),
Err(Refusal::Work) => Err(crate::safety::budget_exceeded(
"web_xpath",
format!(
"an evaluation visiting more than {} nodes",
crate::web_xpath::MAX_WORK
),
"use a narrower expression: fewer // steps, or a path in a predicate over fewer nodes",
)),
Err(Refusal::TooManyResults { limit }) => Err(crate::safety::budget_exceeded(
"web_xpath",
format!("more than {limit} matches"),
"use a narrower expression; this shell returns no partial list",
)),
}
}
// Helper function
fn strip_html_tags(html: &str) -> String {
let mut result = String::new();
let mut in_tag = false;
for c in html.chars() {
if c == '<' {
in_tag = true;
} else if c == '>' {
in_tag = false;
} else if !in_tag {
result.push(c);
}
}
// Clean up whitespace
result.split_whitespace().collect::<Vec<_>>().join(" ")
}
// ============================================================================
// 460-479: Clipboard & Input
// ============================================================================
/// Whether an environment variable names a display: set and not empty.
#[cfg(target_os = "linux")]
fn display_env_set(var: &str) -> bool {
std::env::var_os(var).is_some_and(|v| !v.is_empty())
}
/// The text clipboard builtins run xclip or xsel, which need an X display.
/// With none it is E_NO_UI; under Wayland with no XWayland display they
/// cannot reach the clipboard at all. Checked before any tool runs: xclip
/// with no display failed as E_TOOL_FAILED.
#[cfg(target_os = "linux")]
fn linux_x11_clipboard_display(name: &str) -> Result<()> {
if display_env_set("DISPLAY") {
return Ok(());
}
if display_env_set("WAYLAND_DISPLAY") {
return Err(crate::safety::unimplemented(
name,
"the text clipboard is reached through X11 (xclip or xsel), and this Wayland session has no X display; NOTHING WAS DONE",
"run with XWayland so DISPLAY is set",
));
}
Err(crate::safety::no_ui(name))
}
/// One clipboard tool run for the Linux clipboard builtins. `Ok(None)` when the
/// tool is not installed, so the caller can try the next one. Same deadline and
/// error mapping as bounded_cmd_output, which cannot tell an absent tool
/// (NotFound) apart from the other failures.
#[cfg(target_os = "linux")]
fn linux_clipboard_tool(
name: &str,
tool: &str,
args: &[&str],
) -> Result<Option<std::process::Output>> {
let mut cmd = crate::safety::child_command(tool);
cmd.args(args);
match crate::safety::output_with_timeout(cmd, TOOL_DEADLINE_SECS) {
Ok(out) => Ok(Some(out)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(crate::safety::spawn_error(name, tool, &e)),
}
}
/// The formats `clipboard_types` names, in the order it answers them.
#[cfg(any(target_os = "linux", target_os = "macos"))]
const CLIPBOARD_FORMATS: [&str; 4] = ["text", "image", "files", "audio"];
/// Which of `CLIPBOARD_FORMATS` a clipboard target or MIME type stands for, if
/// any. Everything else (TARGETS, TIMESTAMP, MULTIPLE, SAVE_TARGETS, ...) is
/// ignored.
#[cfg(target_os = "linux")]
fn clipboard_format_index(target: &str) -> Option<usize> {
let t = target.trim().to_ascii_lowercase();
if matches!(t.as_str(), "utf8_string" | "string" | "text") || t.starts_with("text/plain") {
Some(0)
} else if t.starts_with("image/") {
Some(1)
} else if matches!(t.as_str(), "text/uri-list" | "x-special/gnome-copied-files") {
Some(2)
} else if t.starts_with("audio/") {
Some(3)
} else {
None
}
}
/// The answer for a listing of targets or MIME types, one per line: the formats
/// it names, in `CLIPBOARD_FORMATS` order, each once.
#[cfg(target_os = "linux")]
fn clipboard_formats_of(listing: &[u8]) -> Value {
let mut seen = [false; 4];
for line in String::from_utf8_lossy(listing).lines() {
if let Some(i) = clipboard_format_index(line) {
seen[i] = true;
}
}
Value::Array(
CLIPBOARD_FORMATS
.iter()
.zip(seen)
.filter(|(_, present)| *present)
.map(|(name, _)| Value::Str(name.to_string()))
.collect(),
)
}
/// The AppleScript class codes `clipboard info` names, mapped to the formats
/// `clipboard_types` answers. Text is the plain-text classes only (utf8, ut16,
/// TEXT), as on Linux, where text/html alone does not count as text. Images are
/// PNG, TIFF, JPEG, GIF and BMP. Files are file URLs (furl) and aliases (alis).
/// Audio is mp3, M4A, WAVE and AIFF. Any other class is ignored.
#[cfg(target_os = "macos")]
fn macos_format_index(code: &str) -> Option<usize> {
match code {
"utf8" | "ut16" | "TEXT" => Some(0),
"PNGf" | "TIFF" | "JPEG" | "GIFf" | "BMPf" => Some(1),
"furl" | "alis" => Some(2),
"mp3 " | "M4A " | "WAVE" | "AIFF" => Some(3),
_ => None,
}
}
/// The answer for an osascript `clipboard info` listing, which prints the flat
/// list of class and size pairs, e.g. `«class utf8», 5, «class PNGf», 1234`. An
/// empty clipboard prints nothing and answers []. Each format once, in
/// CLIPBOARD_FORMATS order.
#[cfg(target_os = "macos")]
fn macos_clipboard_formats_of(listing: &str) -> Value {
let mut seen = [false; 4];
let inner = listing.trim().trim_start_matches('{').trim_end_matches('}');
for token in inner.split(", ") {
let code = token
.trim()
.strip_prefix("«class ")
.and_then(|t| t.strip_suffix('»'));
if let Some(i) = code.and_then(macos_format_index) {
seen[i] = true;
}
}
Value::Array(
CLIPBOARD_FORMATS
.iter()
.zip(seen)
.filter(|(_, present)| *present)
.map(|(name, _)| Value::Str(name.to_string()))
.collect(),
)
}
#[allow(unused_variables)]
fn bi_clipboard_get(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "clipboard_get";
no_args_or_input(NAME, &args, &input)?;
crate::safety::clipboard_read_gate(NAME)?;
#[cfg(target_os = "windows")]
{
let output = gui_powershell(NAME, "Get-Clipboard")?;
if !output.status.success() {
return Err(crate::safety::tool_failed(
NAME,
"Get-Clipboard",
output.status.code(),
&gui_stderr_tail(&output),
));
}
let text = String::from_utf8_lossy(&output.stdout)
.trim_end()
.to_string();
clipboard_answer(NAME, text.as_bytes())
}
#[cfg(target_os = "macos")]
{
let cmd = crate::safety::child_command("pbpaste");
let output = bounded_cmd_output(NAME, "pbpaste", cmd, TOOL_DEADLINE_SECS)?;
if !output.status.success() {
return Err(crate::safety::tool_failed(
NAME,
"pbpaste",
output.status.code(),
&gui_stderr_tail(&output),
));
}
clipboard_answer(NAME, &output.stdout)
}
#[cfg(target_os = "linux")]
{
linux_x11_clipboard_display(NAME)?;
// xclip reports an empty clipboard as "target not available": that is
// the empty answer, not a failure.
let read = |tool: &str, args: &[&str]| -> Result<Option<Vec<u8>>> {
match linux_clipboard_tool(NAME, tool, args)? {
None => Ok(None),
Some(out) if out.status.success() => Ok(Some(out.stdout)),
Some(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
if stderr.contains("not available") {
Ok(Some(Vec::new()))
} else {
Err(crate::safety::tool_failed(
NAME,
tool,
out.status.code(),
&gui_stderr_tail(&out),
))
}
}
}
};
let bytes = match read("xclip", &["-selection", "clipboard", "-o"])? {
Some(bytes) => bytes,
None => read("xsel", &["--clipboard", "--output"])?.ok_or_else(|| {
crate::safety::tool_missing(
NAME,
"xclip or xsel",
"install xclip or xsel to read the clipboard",
)
})?,
};
clipboard_answer(NAME, &bytes)
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
{
Err(crate::safety::unimplemented(
NAME,
"reading the clipboard is implemented on Windows, macOS and Linux; NOTHING WAS READ",
"run this on Windows, macOS or Linux",
))
}
}
fn bi_clipboard_set(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "clipboard_set";
// The text comes either as the argument or from the pipe, never both. A
// call with neither is refused: it used to answer false.
let text = match args.first() {
Some(v) => {
if input.is_some() {
return Err(crate::safety::bad_arg(
NAME,
"the text as the argument or as piped text, not both",
"both",
));
}
gui_text(NAME, "text", v, CLIPBOARD_MAX_BYTES, true)?
}
None => match input {
Some(piped) => gui_text(NAME, "piped text", &piped, CLIPBOARD_MAX_BYTES, true)?,
None => return Err(crate::safety::bad_arg(NAME, "the text to copy", "nothing")),
},
};
#[cfg(target_os = "windows")]
{
let mut cmd = crate::safety::child_command("powershell");
cmd.args([
"-NoProfile",
"-NonInteractive",
"-Command",
&crate::ps_script!("Set-Clipboard -Value {}", crate::safety::ps_quote(&text)),
]);
let output = bounded_cmd_output(NAME, "powershell", cmd, TOOL_DEADLINE_SECS)?;
if !output.status.success() {
return Err(crate::safety::tool_failed(
NAME,
"Set-Clipboard",
output.status.code(),
&gui_stderr_tail(&output),
));
}
Ok(Value::Bool(true))
}
#[cfg(target_os = "macos")]
{
let mut clip = crate::safety::child_command("pbcopy");
crate::safety::strip_ironshell_env(&mut clip);
let child = clip
.stdin(std::process::Stdio::piped())
.spawn()
.map_err(|e| crate::safety::spawn_error(NAME, "pbcopy", &e))?;
let status = feed_stdin_and_wait(
NAME,
"pbcopy",
child,
text.as_bytes().to_vec(),
TOOL_DEADLINE_SECS,
)?;
if !status.success() {
return Err(crate::safety::tool_failed(
NAME,
"pbcopy",
status.code(),
"it exited unsuccessfully",
));
}
Ok(Value::Bool(true))
}
#[cfg(target_os = "linux")]
{
linux_x11_clipboard_display(NAME)?;
// xclip first, xsel when xclip is not installed. A tool that is
// installed and fails is an error, whichever it is.
let mut xclip = crate::safety::child_command("xclip");
crate::safety::strip_ironshell_env(&mut xclip);
let (tool, child) = match xclip
.args(["-selection", "clipboard"])
.stdin(std::process::Stdio::piped())
.spawn()
{
Ok(child) => ("xclip", child),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => ("xsel", {
let mut xsel = crate::safety::child_command("xsel");
crate::safety::strip_ironshell_env(&mut xsel);
xsel.args(["--clipboard", "--input"])
.stdin(std::process::Stdio::piped())
.spawn()
.map_err(|e| crate::safety::spawn_error(NAME, "xsel", &e))?
}),
Err(e) => return Err(crate::safety::spawn_error(NAME, "xclip", &e)),
};
let status = feed_stdin_and_wait(
NAME,
tool,
child,
text.as_bytes().to_vec(),
TOOL_DEADLINE_SECS,
)?;
if !status.success() {
return Err(crate::safety::tool_failed(
NAME,
tool,
status.code(),
"it exited unsuccessfully",
));
}
Ok(Value::Bool(true))
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
{
Err(crate::safety::unimplemented(
NAME,
"writing the clipboard is implemented on Windows, macOS and Linux; NOTHING WAS COPIED",
"run this on Windows, macOS or Linux",
))
}
}
fn bi_clipboard_clear(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
no_args_or_input("clipboard_clear", &args, &input)?;
bi_clipboard_set(vec![Value::Str(String::new())], None)
}
fn bi_clipboard_has_text(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
no_args_or_input("clipboard_has_text", &args, &input)?;
match bi_clipboard_get(vec![], None)? {
Value::Str(s) => Ok(Value::Bool(!s.is_empty())),
other => Err(crate::safety::bad_arg(
"clipboard_has_text",
"text from the clipboard",
other.type_name(),
)),
}
}
#[allow(unused_variables)]
fn bi_clipboard_types(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
no_args_or_input("clipboard_types", &args, &input)?;
// This always listed "text" -- on Linux and macOS without looking at the
// clipboard at all, on Windows even when the clipboard was empty or held
// an image. It now asks the clipboard what it holds.
#[cfg(target_os = "windows")]
{
let mut cmd = crate::safety::child_command("powershell");
cmd
.args([
"-NoProfile",
"-NonInteractive",
"-STA",
"-Command",
"Add-Type -AssemblyName System.Windows.Forms; $c = [Windows.Forms.Clipboard]; @(if ($c::ContainsText()) {'text'}; if ($c::ContainsImage()) {'image'}; if ($c::ContainsFileDropList()) {'files'}; if ($c::ContainsAudio()) {'audio'}) -join ','",
]);
let output = bounded_cmd_output("clipboard_types", "powershell", cmd, TOOL_DEADLINE_SECS)?;
if !output.status.success() {
return Err(crate::safety::tool_failed(
"clipboard_types",
"powershell",
output.status.code(),
&gui_stderr_tail(&output),
));
}
let text = String::from_utf8_lossy(&output.stdout);
Ok(Value::Array(
text.trim()
.split(',')
.filter(|t| !t.is_empty())
.map(|t| Value::Str(t.to_string()))
.collect(),
))
}
#[cfg(target_os = "linux")]
{
const NAME: &str = "clipboard_types";
// With no display there is no clipboard to ask, and no tool would answer.
if !display_env_set("DISPLAY") && !display_env_set("WAYLAND_DISPLAY") {
return Err(crate::safety::no_ui(NAME));
}
// Wayland first when it is the session: wl-paste reads the clipboard the
// desktop shows, where xclip would see only XWayland's copy.
if display_env_set("WAYLAND_DISPLAY") {
let out =
linux_clipboard_tool(NAME, "wl-paste", &["--list-types"])?.ok_or_else(|| {
crate::safety::tool_missing(
NAME,
"wl-paste",
"install wl-clipboard to list the clipboard's formats",
)
})?;
if out.status.success() {
return Ok(clipboard_formats_of(&out.stdout));
}
// wl-paste exits 1 and says "Nothing is copied" when no client offers data.
if out.status.code() == Some(1)
&& String::from_utf8_lossy(&out.stderr).contains("Nothing is copied")
{
return Ok(Value::Array(Vec::new()));
}
return Err(crate::safety::tool_failed(
NAME,
"wl-paste",
out.status.code(),
&gui_stderr_tail(&out),
));
}
// X11: xclip lists the targets. xsel cannot, so it only says whether the
// clipboard holds text.
if let Some(out) = linux_clipboard_tool(
NAME,
"xclip",
&["-selection", "clipboard", "-o", "-t", "TARGETS"],
)? {
if out.status.success() {
return Ok(clipboard_formats_of(&out.stdout));
}
// xclip reports an empty clipboard as "target TARGETS not available".
if String::from_utf8_lossy(&out.stderr).contains("not available") {
return Ok(Value::Array(Vec::new()));
}
return Err(crate::safety::tool_failed(
NAME,
"xclip",
out.status.code(),
&gui_stderr_tail(&out),
));
}
let Some(out) = linux_clipboard_tool(NAME, "xsel", &["--clipboard", "--output"])? else {
return Err(crate::safety::tool_missing(
NAME,
"xclip or xsel",
"install xclip or xsel to list the clipboard's formats",
));
};
if !out.status.success() {
return Err(crate::safety::tool_failed(
NAME,
"xsel",
out.status.code(),
&gui_stderr_tail(&out),
));
}
Ok(if out.stdout.is_empty() {
Value::Array(Vec::new())
} else {
Value::Array(vec![Value::Str("text".to_string())])
})
}
#[cfg(target_os = "macos")]
{
// `clipboard info` lists the classes on the pasteboard. Nothing is read
// beyond their names and sizes.
let mut cmd = crate::safety::child_command("osascript");
cmd.args(["-e", "clipboard info"]);
let output = bounded_cmd_output("clipboard_types", "osascript", cmd, TOOL_DEADLINE_SECS)?;
if !output.status.success() {
return Err(crate::safety::tool_failed(
"clipboard_types",
"osascript",
output.status.code(),
&gui_stderr_tail(&output),
));
}
Ok(macos_clipboard_formats_of(&String::from_utf8_lossy(
&output.stdout,
)))
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
Err(crate::safety::unimplemented(
"clipboard_types",
"listing clipboard formats is implemented on Windows, macOS and Linux only; NOTHING WAS INSPECTED",
"clipboard() reads the text on the clipboard",
))
}
}
/// Most options one `input_select` or `input_multiselect` offers.
const INPUT_CHOICE_MAX_OPTIONS: usize = 1000;
/// Largest text `input_editor` starts with or answers.
const INPUT_EDITOR_MAX_BYTES: usize = 1024 * 1024;
/// One line from stdin, through the shared bounded reader. Closed stdin (EOF)
/// is `E_NO_UI`: it used to answer "", which read as a real answer.
fn read_answer(builtin: &str, prompt: &str) -> Result<String> {
if !prompt.is_empty() {
eprint!("{}", prompt);
}
stdin_lines::read_blocking(builtin)
}
/// Refuses piped input, and an argument count outside `min..=max`, before
/// anything is printed or read. `shape` is the signature the refusal names.
fn direct_args(
name: &str,
args: &[Value],
piped: bool,
min: usize,
max: usize,
shape: &str,
) -> Result<()> {
if piped {
return Err(crate::safety::bad_arg(
name,
"a direct call; the answer comes from the terminal, not piped input",
"a piped value",
));
}
if !(min..=max).contains(&args.len()) {
return Err(crate::safety::bad_arg(
name,
shape,
&format!("{} argument(s)", args.len()),
));
}
Ok(())
}
/// A prompt: a String of at most `INPUT_TEXT_MAX_BYTES` bytes.
fn prompt_text(name: &str, value: &Value) -> Result<String> {
match value {
Value::Str(s) if s.len() <= INPUT_TEXT_MAX_BYTES => Ok(s.clone()),
Value::Str(s) => Err(crate::safety::bad_arg(
name,
&format!("prompt of at most {INPUT_TEXT_MAX_BYTES} bytes"),
&format!("{} bytes", s.len()),
)),
other => Err(crate::safety::bad_arg(
name,
"prompt: String",
other.type_name(),
)),
}
}
/// The optional first argument as a prompt, or `default` when it is absent.
fn optional_prompt(name: &str, args: &[Value], default: &str) -> Result<String> {
match args.first() {
None => Ok(default.to_string()),
Some(value) => prompt_text(name, value),
}
}
/// An answer as a refusal shows it: its first 64 characters, quoted.
fn echo_answer(answer: &str) -> String {
const SHOWN: usize = 64;
if answer.chars().count() <= SHOWN {
return format!("{answer:?}");
}
let head: String = answer.chars().take(SHOWN).collect();
format!("{head:?}...")
}
/// Asks `question` until `accept` takes the answer. A rejected answer is
/// reported on stderr and asked again, at most `INPUT_MAX_ATTEMPTS` times. After
/// the last one the call refuses with E_BAD_ARG: no value is answered for an
/// answer that was not accepted.
fn ask_until<T>(
name: &str,
question: &str,
expected: &str,
mut accept: impl FnMut(&str) -> Option<T>,
) -> Result<T> {
for _ in 0..INPUT_MAX_ATTEMPTS {
let answer = read_answer(name, question)?;
if let Some(value) = accept(&answer) {
return Ok(value);
}
eprintln!("{name}: {} is not {expected}", echo_answer(&answer));
}
Err(crate::safety::bad_arg(
name,
expected,
&format!("no valid answer in {INPUT_MAX_ATTEMPTS} attempts"),
))
}
/// The options a select offers: 1 through `INPUT_CHOICE_MAX_OPTIONS` values,
/// each a String of 1 through `INPUT_TEXT_MAX_BYTES` bytes, or an Int, Float
/// or Bool. Each comes back with the text its menu line shows; control
/// characters in that text are drawn as spaces.
fn choice_options(name: &str, value: &Value) -> Result<Vec<(Value, String)>> {
let Value::Array(items) = value else {
return Err(crate::safety::bad_arg(
name,
"options: Array of choices",
value.type_name(),
));
};
if items.is_empty() || items.len() > INPUT_CHOICE_MAX_OPTIONS {
return Err(crate::safety::bad_arg(
name,
&format!("1..={INPUT_CHOICE_MAX_OPTIONS} options"),
&format!("{} option(s)", items.len()),
));
}
let mut out = Vec::with_capacity(items.len());
for (i, item) in items.iter().enumerate() {
let text = match item {
Value::Str(s) if s.is_empty() || s.len() > INPUT_TEXT_MAX_BYTES => {
return Err(crate::safety::bad_arg(
name,
&format!("option {}: 1 to {INPUT_TEXT_MAX_BYTES} bytes", i + 1),
&format!("{} bytes", s.len()),
));
}
Value::Str(s) => s.clone(),
Value::Int(_) | Value::Float(_) | Value::Bool(_) => item.to_display_string(),
other => {
return Err(crate::safety::bad_arg(
name,
&format!("option {}: a String, Int, Float or Bool", i + 1),
other.type_name(),
));
}
};
let shown: String = text
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
out.push((item.clone(), shown));
}
Ok(out)
}
/// A multi-select answer: 1-based numbers separated by commas, each in
/// `1..=count`, answered in the order given with repeats dropped. An empty
/// answer is the deliberate choice of nothing. Anything else is `None`.
fn parse_picks(answer: &str, count: usize) -> Option<Vec<usize>> {
let text = answer.trim();
let mut picks = Vec::new();
if text.is_empty() {
return Some(picks);
}
let mut seen = std::collections::BTreeSet::new();
for token in text.split(',') {
let n: usize = token.trim().parse().ok()?;
if !(1..=count).contains(&n) {
return None;
}
if seen.insert(n) {
picks.push(n - 1);
}
}
Some(picks)
}
/// A number answer: an Int when it parses as one, else a finite Float. NaN and
/// the infinities are not numbers an answer can give.
fn parse_number(text: &str) -> Option<Value> {
if let Ok(n) = text.parse::<i64>() {
return Some(Value::Int(n));
}
text.parse::<f64>()
.ok()
.filter(|f| f.is_finite())
.map(Value::Float)
}
/// A date written `YYYY-MM-DD` that the calendar accepts, answered as its text.
fn parse_date(text: &str) -> Option<String> {
let shaped = text.len() == 10
&& text.bytes().enumerate().all(|(i, b)| {
if i == 4 || i == 7 {
b == b'-'
} else {
b.is_ascii_digit()
}
});
if !shaped {
return None;
}
chrono::NaiveDate::parse_from_str(text, "%Y-%m-%d")
.ok()
.map(|_| text.to_string())
}
/// `input_read_line(prompt?)`: one line from stdin, without its terminator.
fn bi_input_read_line(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "input_read_line";
direct_args(
NAME,
&args,
input.is_some(),
0,
1,
"input_read_line(prompt?: String)",
)?;
let prompt = optional_prompt(NAME, &args, "")?;
Ok(Value::Str(read_answer(NAME, &prompt)?))
}
/// `input_read_password(prompt?)`. Piped or closed stdin is read as any other
/// line, so a closed stdin is `E_NO_UI` and nothing is echoed. Only a terminal
/// gets a hidden read, through `read_terminal_password`.
fn bi_input_read_password(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "input_read_password";
direct_args(
NAME,
&args,
input.is_some(),
0,
1,
"input_read_password(prompt?: String)",
)?;
let prompt = optional_prompt(NAME, &args, "Password: ")?;
use std::io::IsTerminal;
if !std::io::stdin().is_terminal() {
return Ok(Value::Str(read_answer(NAME, &prompt)?));
}
Ok(Value::Str(read_terminal_password(NAME, &prompt)?))
}
/// A hidden read from a terminal on Windows: PowerShell's `Read-Host
/// -AsSecureString`. Only the line terminator is removed from the answer.
#[cfg(target_os = "windows")]
fn read_terminal_password(name: &str, prompt: &str) -> Result<String> {
let ps_script = crate::ps_script!(
r#"
$password = Read-Host -Prompt {} -AsSecureString
$bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($password)
[System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
"#,
crate::safety::ps_quote(prompt.trim_end_matches(": ").trim_end_matches(':'))
);
let mut shell = crate::safety::child_command("powershell");
crate::safety::strip_ironshell_env(&mut shell);
let output = shell
.args(["-Command", &ps_script])
.output()
.map_err(|e| crate::safety::spawn_error(name, "powershell", &e))?;
if !output.status.success() {
return Err(crate::safety::tool_failed(
name,
"powershell",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
Ok(String::from_utf8_lossy(&output.stdout)
.trim_end_matches(['\r', '\n'])
.to_string())
}
/// A hidden read from a terminal on Unix: echo is off for the one line.
#[cfg(unix)]
fn read_terminal_password(name: &str, prompt: &str) -> Result<String> {
eprint!("{prompt}");
let mut stty = crate::safety::child_command("stty");
crate::safety::strip_ironshell_env(&mut stty);
let _ = stty.arg("-echo").status();
let answer = stdin_lines::read_blocking(name);
let mut stty = crate::safety::child_command("stty");
crate::safety::strip_ironshell_env(&mut stty);
let _ = stty.arg("echo").status();
eprintln!();
answer
}
/// Other platforms have no hidden read, so the line is read as any other.
#[cfg(not(any(unix, target_os = "windows")))]
fn read_terminal_password(name: &str, prompt: &str) -> Result<String> {
read_answer(name, prompt)
}
/// `input_confirm(prompt?)`: `y` or `yes` answers true; `n`, `no` or an empty
/// answer answers false. Anything else is asked again.
fn bi_input_confirm(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "input_confirm";
direct_args(
NAME,
&args,
input.is_some(),
0,
1,
"input_confirm(prompt?: String)",
)?;
let prompt = optional_prompt(NAME, &args, "Confirm? [y/N]: ")?;
ask_until(
NAME,
&prompt,
"y, yes, n, no or an empty answer",
|answer| match answer.trim().to_lowercase().as_str() {
"y" | "yes" => Some(true),
"" | "n" | "no" => Some(false),
_ => None,
},
)
.map(Value::Bool)
}
/// `input_select(prompt, options)`: lists the options numbered, then answers
/// the one picked, as it was given. An answer that is not a number in 1..=N is
/// asked again; after the last attempt the call refuses with E_BAD_ARG.
fn bi_input_select(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "input_select";
direct_args(
NAME,
&args,
input.is_some(),
2,
2,
"input_select(prompt: String, options: Array)",
)?;
let prompt = prompt_text(NAME, &args[0])?;
let options = choice_options(NAME, &args[1])?;
eprintln!("{prompt}");
for (i, (_, text)) in options.iter().enumerate() {
eprintln!(" {}. {text}", i + 1);
}
let count = options.len();
let index = ask_until(
NAME,
"Choice: ",
&format!("a number in 1..={count}"),
|answer| {
answer
.trim()
.parse::<usize>()
.ok()
.filter(|n| (1..=count).contains(n))
.map(|n| n - 1)
},
)?;
Ok(options[index].0.clone())
}
/// `input_multiselect(prompt, options)`: lists the options numbered, then
/// answers the picked ones, in the order given, as an Array. An empty answer
/// answers `[]`. An invalid answer is asked again; after the last attempt the
/// call refuses with E_BAD_ARG.
fn bi_input_multiselect(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "input_multiselect";
direct_args(
NAME,
&args,
input.is_some(),
2,
2,
"input_multiselect(prompt: String, options: Array)",
)?;
let prompt = prompt_text(NAME, &args[0])?;
let options = choice_options(NAME, &args[1])?;
eprintln!("{prompt}");
for (i, (_, text)) in options.iter().enumerate() {
eprintln!(" {}. {text}", i + 1);
}
let count = options.len();
let picks = ask_until(
NAME,
"Choices: ",
&format!("numbers in 1..={count} separated by commas, or an empty answer"),
|answer| parse_picks(answer, count),
)?;
Ok(Value::Array(
picks.into_iter().map(|i| options[i].0.clone()).collect(),
))
}
/// `input_number(prompt?)`: an Int or a finite Float. Other text is asked again;
/// after the last attempt the call refuses with E_BAD_ARG.
fn bi_input_number(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "input_number";
direct_args(
NAME,
&args,
input.is_some(),
0,
1,
"input_number(prompt?: String)",
)?;
let prompt = optional_prompt(NAME, &args, "Enter number: ")?;
ask_until(NAME, &prompt, "a number", |answer| {
parse_number(answer.trim())
})
}
/// `input_editor(initial?)`: opens the editor named by `EDITOR` on a temporary
/// file that starts as `initial`, waits for it to exit, and answers the text
/// saved. It needs a terminal on stdin and stdout; without one it refuses with
/// E_NO_UI and runs nothing. An editor that exits unsuccessfully is a coded
/// E_TOOL_FAILED.
fn bi_input_editor(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "input_editor";
direct_args(
NAME,
&args,
input.is_some(),
0,
1,
"input_editor(initial?: String)",
)?;
let initial = match args.first() {
None => String::new(),
Some(Value::Str(s)) if s.len() <= INPUT_EDITOR_MAX_BYTES => s.clone(),
Some(Value::Str(s)) => {
return Err(crate::safety::bad_arg(
NAME,
&format!("initial text of at most {INPUT_EDITOR_MAX_BYTES} bytes"),
&format!("{} bytes", s.len()),
))
}
Some(other) => {
return Err(crate::safety::bad_arg(
NAME,
"initial: String",
other.type_name(),
))
}
};
use std::io::IsTerminal;
if !(std::io::stdin().is_terminal() && std::io::stdout().is_terminal()) {
return Err(crate::safety::no_ui(NAME));
}
let editor = editor_program(NAME)?;
let path = create_edit_file(NAME, &initial)?;
let answer = run_editor(NAME, &editor, &path).and_then(|()| read_edit_file(NAME, &path));
let _ = std::fs::remove_file(&path);
answer.map(Value::Str)
}
/// The editor program: the `EDITOR` variable, or notepad on Windows and vi
/// elsewhere. It names one program, run without a shell, so its text is not
/// split into arguments. A value that starts with `-` is refused, since the
/// editor would read it as an option.
fn editor_program(name: &str) -> Result<String> {
let configured = match std::env::var("EDITOR") {
Ok(value) => value,
Err(std::env::VarError::NotPresent) => String::new(),
Err(std::env::VarError::NotUnicode(_)) => {
return Err(crate::safety::bad_arg(
name,
"EDITOR as Unicode text naming one program",
"non-Unicode bytes",
))
}
};
let editor = match configured.trim() {
"" if cfg!(target_os = "windows") => "notepad".to_string(),
"" => "vi".to_string(),
value => value.to_string(),
};
if editor.starts_with('-') {
return Err(crate::safety::bad_arg(
name,
"EDITOR naming a program, not an option",
&echo_answer(&editor),
));
}
if editor.chars().any(char::is_control) {
return Err(crate::safety::bad_arg(
name,
"EDITOR without control characters",
"a control character",
));
}
Ok(editor)
}
/// Writes `text` to a new file in the temporary directory, under a name no
/// other file holds. The file is created exclusively and, on Unix, readable
/// only by its owner.
fn create_edit_file(name: &str, text: &str) -> Result<std::path::PathBuf> {
use std::io::Write;
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
for attempt in 0..16u32 {
let path = std::env::temp_dir().join(format!(
"ironshell_edit_{}_{stamp}_{attempt}.txt",
std::process::id()
));
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
match options.open(&path) {
Ok(mut file) => {
if let Err(e) = file.write_all(text.as_bytes()) {
let _ = std::fs::remove_file(&path);
return Err(crate::safety::fs_error(name, &path, &e));
}
return Ok(path);
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(crate::safety::fs_error(name, &path, &e)),
}
}
Err(crate::safety::fs_error(
name,
std::env::temp_dir(),
&std::io::Error::other("no free temporary file name"),
))
}
/// Runs `editor` on `path` with the terminal inherited, without a shell.
fn run_editor(name: &str, editor: &str, path: &std::path::Path) -> Result<()> {
let mut cmd = crate::safety::child_command(editor);
crate::safety::strip_ironshell_env(&mut cmd);
let status = cmd
.arg(path)
.status()
.map_err(|e| crate::safety::spawn_error(name, editor, &e))?;
if status.success() {
return Ok(());
}
Err(crate::safety::tool_failed(
name,
editor,
status.code(),
"the editor exited without a successful status; nothing was answered",
))
}
/// The text saved in the temporary file, read through a bounded reader. A
/// file longer than `INPUT_EDITOR_MAX_BYTES` is refused, not truncated.
fn read_edit_file(name: &str, path: &std::path::Path) -> Result<String> {
use std::io::Read;
let file = std::fs::File::open(path).map_err(|e| crate::safety::fs_error(name, path, &e))?;
let mut bytes = Vec::new();
file.take(INPUT_EDITOR_MAX_BYTES as u64 + 1)
.read_to_end(&mut bytes)
.map_err(|e| crate::safety::fs_error(name, path, &e))?;
if bytes.len() > INPUT_EDITOR_MAX_BYTES {
return Err(crate::safety::budget_exceeded(
name,
format!(
"an edited text of at most {} KiB",
INPUT_EDITOR_MAX_BYTES / 1024
),
"the text was not answered; save a shorter text",
));
}
String::from_utf8(bytes).map_err(|_| {
crate::safety::fs_error(
name,
path,
&std::io::Error::new(std::io::ErrorKind::InvalidData, "the text is not UTF-8"),
)
})
}
/// `input_timeout(prompt, timeout_ms)`: one stdin line, or null when
/// `timeout_ms` passes first. The line typed after the timeout is not lost;
/// it answers the next prompt.
fn bi_input_timeout(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const BUILTIN: &str = "input_timeout";
const MAX_MS: i64 = 3_600_000;
if input.is_some() {
return Err(crate::safety::bad_arg(
BUILTIN,
"no pipeline input",
"a piped value",
));
}
let [prompt, timeout] = args.as_slice() else {
return Err(crate::safety::bad_arg(
BUILTIN,
"input_timeout(prompt: String, timeout_ms: Int)",
&format!("{} arguments", args.len()),
));
};
let Value::Str(prompt) = prompt else {
return Err(crate::safety::bad_arg(
BUILTIN,
"a String prompt",
&format!("{prompt:?}"),
));
};
let ms = match timeout {
Value::Int(ms) if (1..=MAX_MS).contains(ms) => *ms,
other => {
return Err(crate::safety::bad_arg(
BUILTIN,
"an Int timeout_ms from 1 through 3600000",
&format!("{other:?}"),
))
}
};
eprint!("{prompt}");
match stdin_lines::read_line(BUILTIN, Some(std::time::Duration::from_millis(ms as u64)))? {
Some(line) => Ok(Value::Str(line)),
None => Ok(Value::Null),
}
}
/// Asks for one of `choices`. An exact match wins; otherwise a prefix that
/// matches exactly one choice, ignoring case, wins. Anything else lists the
/// candidates (or says there are none) and asks again.
fn bi_input_autocomplete(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "input_autocomplete";
if input.is_some() {
return Err(crate::safety::bad_arg(
NAME,
"a direct call; the answer comes from the terminal, not piped input",
"a piped value",
));
}
let [prompt, choices] = args.as_slice() else {
return Err(crate::safety::bad_arg(
NAME,
"prompt: String, choices: Array of Strings",
&format!("{} argument(s)", args.len()),
));
};
let Value::Str(prompt) = prompt else {
return Err(crate::safety::bad_arg(
NAME,
"prompt: String",
prompt.type_name(),
));
};
let Value::Array(choices) = choices else {
return Err(crate::safety::bad_arg(
NAME,
"choices: Array of Strings",
choices.type_name(),
));
};
if choices.is_empty() || choices.len() > INPUT_AUTOCOMPLETE_MAX_CHOICES {
return Err(crate::safety::bad_arg(
NAME,
&format!("1..={INPUT_AUTOCOMPLETE_MAX_CHOICES} choices"),
&format!("{} choice(s)", choices.len()),
));
}
let mut texts: Vec<&str> = Vec::with_capacity(choices.len());
let mut seen = std::collections::BTreeSet::new();
for (i, choice) in choices.iter().enumerate() {
let Value::Str(text) = choice else {
return Err(crate::safety::bad_arg(
NAME,
&format!("choice {}: a String", i + 1),
choice.type_name(),
));
};
if text.is_empty() || text.len() > INPUT_TEXT_MAX_BYTES {
return Err(crate::safety::bad_arg(
NAME,
&format!("choice {}: 1 to {INPUT_TEXT_MAX_BYTES} bytes", i + 1),
&format!("{} bytes", text.len()),
));
}
if !seen.insert(text.as_str()) {
return Err(crate::safety::bad_arg(
NAME,
"choices that are unique",
&format!("duplicate {text:?}"),
));
}
texts.push(text.as_str());
}
for _ in 0..INPUT_AUTOCOMPLETE_MAX_ATTEMPTS {
let answer = read_answer(NAME, prompt)?;
if let Some(exact) = texts.iter().find(|c| **c == answer) {
return Ok(Value::Str((*exact).to_string()));
}
let lower = answer.to_lowercase();
let matches: Vec<&str> = texts
.iter()
.copied()
.filter(|c| c.to_lowercase().starts_with(lower.as_str()))
.collect();
match matches.as_slice() {
[only] => return Ok(Value::Str((*only).to_string())),
[] => eprintln!("{NAME}: no match for {answer:?}"),
many => {
eprintln!("{NAME}: {} matches for {answer:?}:", many.len());
for m in many.iter().take(INPUT_AUTOCOMPLETE_LIST) {
eprintln!(" {m}");
}
if many.len() > INPUT_AUTOCOMPLETE_LIST {
eprintln!(" ... and {} more", many.len() - INPUT_AUTOCOMPLETE_LIST);
}
}
}
}
Err(crate::safety::bad_arg(
NAME,
"one of the choices, or a prefix that matches exactly one",
&format!("no valid answer in {INPUT_AUTOCOMPLETE_MAX_ATTEMPTS} attempts"),
))
}
// ============================================================================
// 480-499: Security & Crypto
// ============================================================================
/// One-shot digest through IronCrypto, as lowercase hex. A refusal is the
/// coded `E_CRYPTO_FAILED` naming `builtin`.
fn ic_digest_hex<D: ironcrypto::core_types::traits::Digest>(
builtin: &str,
bytes: &[u8],
) -> Result<String> {
let out = D::digest(bytes).map_err(|e| crate::safety::ic_failed(builtin, e))?;
Ok(hex::encode(out))
}
/// One-shot MAC through IronCrypto, as lowercase hex.
fn ic_mac_hex<M: ironcrypto::core_types::traits::Mac>(
builtin: &str,
key: &[u8],
data: &[u8],
) -> Result<String> {
let tag = M::mac(key, data).map_err(|e| crate::safety::ic_failed(builtin, e))?;
Ok(hex::encode(tag))
}
/// SHA-256 of `bytes` through IronCrypto, as raw bytes.
fn ic_sha256(builtin: &str, bytes: &[u8]) -> Result<[u8; 32]> {
<ironcrypto::hash::Sha256 as ironcrypto::core_types::traits::Digest>::digest(bytes)
.map_err(|e| crate::safety::ic_failed(builtin, e))
}
/// Hex digest of `bytes`, in-process.
///
/// These builtins shelled out -- `sha256sum`/`md5sum` on Unix, PowerShell on
/// Windows -- although `sha2`, `md5` and `sha1` are linked. Worse, a name the
/// match did not know fell through to SHA-256, so `crypto_hash(x, "blake3")`
/// returned a SHA-256 digest labelled as whatever was asked for.
fn digest_hex(builtin: &str, algo: &str, bytes: &[u8]) -> Result<String> {
crate::safety::require_fips_hash(algo)?;
let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
Ok(match algo.to_ascii_lowercase().as_str() {
// MD5: IronCrypto has none; a legacy digest the caller asked for (FIPS refuses it above).
"md5" => format!("{:x}", md5::compute(bytes)),
// SHA-1: IronCrypto has none, so it stays on the sha1 crate.
"sha1" | "sha-1" => hex(&<sha1::Sha1 as sha1::Digest>::digest(bytes)),
"sha256" | "sha-256" => ic_digest_hex::<ironcrypto::hash::Sha256>(builtin, bytes)?,
"sha384" | "sha-384" => ic_digest_hex::<ironcrypto::hash::Sha384>(builtin, bytes)?,
"sha512" | "sha-512" => ic_digest_hex::<ironcrypto::hash::Sha512>(builtin, bytes)?,
other => {
return Err(crate::safety::bad_arg(
builtin,
"algorithm md5, sha1, sha256, sha384 or sha512",
&format!("{other:?}"),
))
}
})
}
/// `(subject, the arguments after it)`: piped, the subject is the input and
/// every argument is a parameter; called directly, it is the first argument.
/// These builtins took `args[0]` as the data even when a value was piped, so
/// `"x" | crypto_hash("md5")` hashed the string "md5".
fn subject_and_params(
builtin: &str,
args: &[Value],
input: Option<Value>,
) -> Result<(String, Vec<Value>)> {
let (subject, params) = match input {
Some(v) => (Some(v), args.to_vec()),
None => (args.first().cloned(), args.get(1..).unwrap_or(&[]).to_vec()),
};
match subject {
Some(Value::Str(s)) => Ok((s, params)),
other => Err(crate::safety::bad_arg(
builtin,
"a String (argument or piped input)",
other.as_ref().map(|v| v.type_name()).unwrap_or("nothing"),
)),
}
}
fn algo_param(builtin: &str, params: &[Value], default: &str) -> Result<String> {
match params.first() {
None => Ok(default.to_string()),
Some(Value::Str(s)) => Ok(s.clone()),
Some(other) => Err(crate::safety::bad_arg(
builtin,
"algorithm: String",
other.type_name(),
)),
}
}
fn bi_crypto_hash(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let (data, params) = subject_and_params("crypto_hash", &args, input)?;
let algo = algo_param("crypto_hash", ¶ms, "sha256")?;
Ok(Value::Str(digest_hex(
"crypto_hash",
&algo,
data.as_bytes(),
)?))
}
/// `md5` and `sha256` shared `crypto_hash`'s dispatch row, and its default is
/// SHA-256, so `md5("hello")` returned the SHA-256 of "hello". Each now has
/// its own row with its algorithm fixed.
fn bi_named_hash(name: &str, args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let (data, params) = subject_and_params(name, &args, input)?;
if let Some(extra) = params.first() {
return Err(crate::safety::bad_arg(
name,
&format!("only the data: {name} fixes its algorithm"),
extra.type_name(),
));
}
Ok(Value::Str(digest_hex(name, name, data.as_bytes())?))
}
fn bi_crypto_hash_file(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"crypto_hash_file",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let algo = algo_param("crypto_hash_file", args.get(1..).unwrap_or(&[]), "sha256")?;
let validated = crate::security::validate_read_path(&path)?;
let bytes = read_bytes_capped("crypto_hash_file", &validated)?;
Ok(Value::Str(digest_hex("crypto_hash_file", &algo, &bytes)?))
}
fn bi_crypto_hmac(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
use hmac::{Hmac, Mac};
let data = args
.first()
.ok_or_else(|| crate::safety::arg_err("crypto_hmac: data is required"))?;
let data = expect_string("crypto_hmac", data)?;
let key = args
.get(1)
.ok_or_else(|| crate::safety::arg_err("crypto_hmac: key is required"))?;
let key = expect_string("crypto_hmac", key)?;
let algo =
algo_param("crypto_hmac", args.get(2..).unwrap_or(&[]), "sha256")?.to_ascii_lowercase();
crate::safety::require_fips_hash(&algo)?;
macro_rules! authenticate {
($digest:ty) => {{
let mut mac = Hmac::<$digest>::new_from_slice(key.as_bytes())
.map_err(|_| crate::safety::arg_err("crypto_hmac: invalid key"))?;
mac.update(data.as_bytes());
mac.finalize()
.into_bytes()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
}};
}
// HMAC-SHA-1 stays on the hmac crate: IronCrypto implements no SHA-1.
let result = match algo.as_str() {
"sha1" | "sha-1" => authenticate!(sha1::Sha1),
"sha256" | "sha-256" => ic_mac_hex::<ironcrypto::mac::HmacSha256>(
"crypto_hmac",
key.as_bytes(),
data.as_bytes(),
)?,
"sha384" | "sha-384" => ic_mac_hex::<ironcrypto::mac::HmacSha384>(
"crypto_hmac",
key.as_bytes(),
data.as_bytes(),
)?,
"sha512" | "sha-512" => ic_mac_hex::<ironcrypto::mac::HmacSha512>(
"crypto_hmac",
key.as_bytes(),
data.as_bytes(),
)?,
"md5" => {
// HMAC-MD5 by hand (RFC 2104): IronCrypto has no MD5, and md5 0.7 predates the Digest trait.
let shortened;
let key = if key.len() > 64 {
shortened = md5::compute(key.as_bytes()).0;
&shortened[..]
} else {
key.as_bytes()
};
let mut inner_pad = [0x36; 64];
let mut outer_pad = [0x5c; 64];
for (i, byte) in key.iter().enumerate() {
inner_pad[i] ^= byte;
outer_pad[i] ^= byte;
}
let mut inner = md5::Context::new();
inner.consume(inner_pad);
inner.consume(data.as_bytes());
let mut outer = md5::Context::new();
outer.consume(outer_pad);
outer.consume(inner.compute().0);
format!("{:x}", outer.compute())
}
other => {
return Err(crate::safety::bad_arg(
"crypto_hmac",
"algorithm md5, sha1, sha256, sha384 or sha512",
other,
))
}
};
Ok(Value::Str(result))
}
fn bi_crypto_uuid(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
Ok(Value::Str(uuid::Uuid::new_v4().to_string()))
}
fn bi_crypto_random_bytes(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let count = args
.first()
.map(|v| expect_int("crypto_random_bytes", v))
.transpose()?
.unwrap_or(32);
if !(0..=1_048_576).contains(&count) {
return Err(crate::safety::arg_err(
"crypto_random_bytes: count must be in 0..1048576",
));
}
let mut bytes = vec![0u8; count as usize];
crate::safety::ic_fill_random("crypto_random_bytes", &mut bytes)?;
Ok(Value::Str(
bytes.iter().map(|byte| format!("{byte:02x}")).collect(),
))
}
/// `length` characters drawn uniformly from `chars`.
///
/// `fill` supplies random bytes. Each character takes one 32-bit draw, and a
/// draw at or above the largest multiple of `chars.len()` that fits in 2^32 is
/// rejected, so no character is favoured by the modulo (a plain `% len` over a
/// 32-bit draw favours the first `2^32 % len` characters).
fn random_chars(
chars: &[char],
length: usize,
mut fill: impl FnMut(&mut [u8]) -> Result<()>,
) -> Result<String> {
let n = chars.len() as u64;
if n == 0 || n > 1 << 32 {
return Err(crate::safety::arg_err(
"crypto_random_string: charset must hold between 1 and 2^32 characters",
));
}
let limit = (1u64 << 32) / n * n;
let mut out = String::with_capacity(length);
let mut produced = 0usize;
let mut buf: Vec<u8> = Vec::new();
let mut at = 0usize;
while produced < length {
if at + 4 > buf.len() {
// Enough for every character still owed, even if none is rejected.
buf = vec![0u8; 4 * (length - produced)];
fill(&mut buf)?;
at = 0;
}
let draw = u32::from_le_bytes([buf[at], buf[at + 1], buf[at + 2], buf[at + 3]]) as u64;
at += 4;
if draw < limit {
out.push(chars[(draw % n) as usize]);
produced += 1;
}
}
Ok(out)
}
fn bi_crypto_random_string(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let length = args
.first()
.map(|v| expect_int("crypto_random_string", v))
.transpose()?
.unwrap_or(32);
if !(0..=1_048_576).contains(&length) {
return Err(crate::safety::arg_err(
"crypto_random_string: length must be in 0..1048576",
));
}
let charset = args
.get(1)
.map(|v| expect_string("crypto_random_string", v))
.transpose()?
.unwrap_or("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789");
let chars: Vec<char> = charset.chars().collect();
if chars.is_empty() {
return Err(crate::safety::arg_err(
"crypto_random_string: charset must not be empty",
));
}
let result = random_chars(&chars, length as usize, |buf| {
crate::safety::ic_fill_random("crypto_random_string", buf)
})?;
Ok(Value::Str(result))
}
#[cfg(test)]
mod hash_rng_vector_tests {
use super::*;
fn text(v: Result<Value>) -> String {
match v {
Ok(Value::Str(s)) => s,
other => panic!("expected a String result, got {other:?}"),
}
}
#[test]
fn sha2_digests_match_fips_180_4() {
// FIPS 180-4 example vectors, and the empty-message digests.
let abc = b"abc";
assert_eq!(
digest_hex("t", "sha256", abc).unwrap(),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
assert_eq!(
digest_hex("t", "sha384", abc).unwrap(),
"cb00753f45a35e8bb5a03d699ac65007272c32ab0eded1631a8b605a43ff5bed8086072ba1e7cc2358baeca134c825a7"
);
assert_eq!(
digest_hex("t", "sha512", abc).unwrap(),
"ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f"
);
assert_eq!(
digest_hex("t", "sha256", b"").unwrap(),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
assert_eq!(
digest_hex("t", "sha384", b"").unwrap(),
"38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da274edebfe76f65fbd51ad2f14898b95b"
);
assert_eq!(
digest_hex("t", "sha512", b"").unwrap(),
"cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e"
);
}
#[test]
fn hmac_sha2_matches_rfc_4231_cases_1_and_2() {
let case1_key = "\u{0b}".repeat(20);
let hmac = |data: &str, key: &str, algo: &str| {
text(bi_crypto_hmac(
vec![
Value::Str(data.to_string()),
Value::Str(key.to_string()),
Value::Str(algo.to_string()),
],
None,
))
};
assert_eq!(
hmac("Hi There", &case1_key, "sha256"),
"b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
);
assert_eq!(
hmac("Hi There", &case1_key, "sha384"),
"afd03944d84895626b0825f4ab46907f15f9dadbe4101ec682aa034c7cebc59cfaea9ea9076ede7f4af152e8b2fa9cb6"
);
assert_eq!(
hmac("Hi There", &case1_key, "sha512"),
"87aa7cdea5ef619d4ff0b4241a1d6cb02379f4e2ce4ec2787ad0b30545e17cdedaa833b7d6b8a702038b274eaea3f4e4be9d914eeb61f1702e696c203a126854"
);
assert_eq!(
hmac("what do ya want for nothing?", "Jefe", "sha256"),
"5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
);
assert_eq!(
hmac("what do ya want for nothing?", "Jefe", "sha384"),
"af45d2e376484031617f78d2b58a6b1b9c7ef464f5a01b47e42ec3736322445e8e2240ca5e69e2c78b3239ecfab21649"
);
assert_eq!(
hmac("what do ya want for nothing?", "Jefe", "sha512"),
"164b7a7bfcf819e2e395fbe73b56e0a387bd64222e831fd610270cd7ea2505549758bf75c05a994a6d034f65f8f0e6fdcaeab1a34d4a6b4b636e070a38bce737"
);
}
#[test]
fn random_chars_rejects_draws_that_modulo_would_bias() {
// Three characters: 2^32 is not a multiple of 3, so the draw 0xFFFFFFFF
// (which is 0 modulo 3, i.e. 'a') must be rejected, not mapped to 'a'.
let mut stream = vec![0xff, 0xff, 0xff, 0xff, 4, 0, 0, 0, 5, 0, 0, 0].into_iter();
let chars: Vec<char> = "abc".chars().collect();
let out = random_chars(&chars, 2, |buf| {
for b in buf.iter_mut() {
*b = stream.next().unwrap_or(0);
}
Ok(())
})
.unwrap();
assert_eq!(out, "bc");
}
#[test]
fn random_string_uses_only_the_alphabet_at_the_requested_length() {
let alphabet = "ABC";
let out = text(bi_crypto_random_string(
vec![Value::Int(3000), Value::Str(alphabet.to_string())],
None,
));
assert_eq!(out.chars().count(), 3000);
assert!(out.chars().all(|c| alphabet.contains(c)), "{out}");
// Each of three symbols should appear roughly 1000 times; a missing or
// dominant symbol would mean the sampler is broken.
for c in alphabet.chars() {
let n = out.chars().filter(|&x| x == c).count();
assert!((800..=1200).contains(&n), "{c} appeared {n} times");
}
}
}
fn bi_crypto_base64_encode(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
// In-process. This piped the data through `base64` on Unix and PowerShell
// on Windows: GNU base64 wraps at 76 columns and PowerShell does not, so a
// long input encoded differently by OS, and any failure answered "".
use base64::{engine::general_purpose::STANDARD, Engine as _};
let data = hex_subject("crypto_base64_encode", &args, input)?;
Ok(Value::Str(STANDARD.encode(data.as_bytes())))
}
fn bi_crypto_base64_decode(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
use base64::{engine::general_purpose::STANDARD, Engine as _};
let data = hex_subject("crypto_base64_decode", &args, input)?;
// Wrapped input (as `base64` writes it) decodes: whitespace is not data.
let compact: String = data.chars().filter(|c| !c.is_ascii_whitespace()).collect();
let bytes = STANDARD.decode(compact.as_bytes()).map_err(|e| {
crate::safety::bad_arg("crypto_base64_decode", "valid base64", &e.to_string())
})?;
// Bytes that are not text were replaced with U+FFFD, silently.
String::from_utf8(bytes).map(Value::Str).map_err(|_| {
crate::safety::bad_arg(
"crypto_base64_decode",
"base64 of UTF-8 text (these bytes are binary)",
"non-UTF-8 bytes",
)
})
}
/// The string a hex builtin works on: the argument, else the piped value.
/// Both answered "" when there was none, which reads as "encoded nothing".
fn hex_subject(builtin: &str, args: &[Value], input: Option<Value>) -> Result<String> {
match args.first().cloned().or(input) {
Some(Value::Str(s)) => Ok(s),
other => Err(crate::safety::bad_arg(
builtin,
"a String (argument or piped input)",
other.as_ref().map(|v| v.type_name()).unwrap_or("nothing"),
)),
}
}
fn bi_crypto_hex_encode(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let data = hex_subject("crypto_hex_encode", &args, input)?;
let hex: String = data.bytes().map(|b| format!("{:02x}", b)).collect();
Ok(Value::Str(hex))
}
fn bi_crypto_hex_decode(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
// This sliced `data[i..i + 2]` on byte offsets: an odd length read past
// the end and a non-ASCII character split a code point, and both
// panicked. Invalid pairs were skipped, so "zz41" decoded to "A".
let data = hex_subject("crypto_hex_decode", &args, input)?;
let digits = data.as_bytes();
if digits.len() % 2 != 0 || !digits.iter().all(u8::is_ascii_hexdigit) {
return Err(crate::safety::bad_arg(
"crypto_hex_decode",
"hex digits in pairs",
&format!("{:?}", data.chars().take(40).collect::<String>()),
));
}
let bytes: Vec<u8> = digits
.chunks(2)
.map(|p| u8::from_str_radix(std::str::from_utf8(p).unwrap_or("00"), 16).unwrap_or(0))
.collect();
Ok(Value::Str(String::from_utf8_lossy(&bytes).to_string()))
}
/// Envelope version tag for authenticated ciphertext. A ciphertext that does
/// not begin with this is from before AS-2026-04 was fixed and carries no
/// integrity protection; `bi_crypto_decrypt` refuses it unless the caller opts
/// in, so an attacker cannot strip the envelope to downgrade a modern
/// ciphertext onto the unauthenticated path.
const CRYPTO_ENVELOPE_V1: &str = "AE1";
/// Additional authenticated data for the original `AE1` envelope.
///
/// Frozen, through the IronShell rename too: 10.0.0 shipped ciphertext
/// authenticated under exactly these bytes, and changing them would make every
/// one of those ciphertexts undecryptable.
const CRYPTO_AAD: &[u8] = b"AetherShell-AE1";
/// AAD for the FIPS envelope, which carries its own version.
const CRYPTO_AAD_FIPS: &[u8] = b"AetherShell-AE1F";
/// Envelope version tag for ciphertext whose key was derived with PBKDF2
/// rather than Argon2id.
///
/// Argon2id is the better choice against offline cracking and stays the
/// default. It is also not FIPS-approved, which left `crypto.encrypt` with one
/// non-approved primitive in an otherwise approved chain (AS-2026-08). Under
/// `IRONSHELL_FIPS` the key is derived with PBKDF2-HMAC-SHA256 (SP 800-132)
/// instead and the envelope says so, so a deployment that must be
/// all-approved can be, without changing what anyone else gets.
const CRYPTO_ENVELOPE_V1_FIPS: &str = "AE1F";
/// Iterations for the PBKDF2 path.
///
/// Deliberately a constant rather than a field in the envelope: the header is
/// not authenticated until after key derivation, so a count carried in the
/// ciphertext would let an attacker demand an arbitrarily expensive derivation
/// before the tag could reject it. Changing this value means minting a new
/// version tag.
const CRYPTO_PBKDF2_ROUNDS: u32 = 600_000;
/// Which key-derivation function an envelope tag selects.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum CryptoKdf {
Argon2id,
Pbkdf2HmacSha256,
}
impl CryptoKdf {
/// The KDF to use for new ciphertext.
fn for_new_ciphertext() -> Self {
if crate::safety::fips_enabled() {
Self::Pbkdf2HmacSha256
} else {
Self::Argon2id
}
}
/// The AEAD's additional data, which differs per version so that the
/// version tag is *authenticated* rather than merely load-bearing.
///
/// Without this, swapping `AE1F` for `AE1` on a ciphertext is rejected only
/// because the two tags select different KDFs and therefore different keys
/// — true today, and an accident rather than a property. Binding the
/// version into the AAD makes the rejection structural: it holds even if
/// two versions ever derive the same key.
fn aad(self) -> &'static [u8] {
match self {
Self::Argon2id => CRYPTO_AAD,
Self::Pbkdf2HmacSha256 => CRYPTO_AAD_FIPS,
}
}
fn tag(self) -> &'static str {
match self {
Self::Argon2id => CRYPTO_ENVELOPE_V1,
Self::Pbkdf2HmacSha256 => CRYPTO_ENVELOPE_V1_FIPS,
}
}
/// Decrypt reads the KDF from the envelope, never from the ambient mode:
/// ciphertext written under `IRONSHELL_FIPS` must stay readable without it,
/// and vice versa.
fn from_tag(tag: &str) -> Option<Self> {
match tag {
CRYPTO_ENVELOPE_V1 => Some(Self::Argon2id),
CRYPTO_ENVELOPE_V1_FIPS => Some(Self::Pbkdf2HmacSha256),
_ => None,
}
}
}
/// Derive a 32-byte AEAD key from a password.
///
/// Argon2id by default -- the same KDF `auth.rs` uses for password storage,
/// for the same reason: the threat is a precomputed table over a small
/// guessable space, and a fast hash invites one.
fn crypto_derive_key(
builtin: &str,
password: &str,
salt: &[u8],
kdf: CryptoKdf,
) -> Result<[u8; 32]> {
let mut key = [0u8; 32];
match kdf {
// Argon2id v1.3 at the old `Argon2::default()` cost, so an envelope
// written by 10.x-12.x derives the same key.
CryptoKdf::Argon2id => ironcrypto::kdf::argon2::argon2(
ironcrypto::kdf::argon2::Variant::Argon2id,
&ironcrypto::kdf::argon2::Argon2Params::DEFAULT,
password.as_bytes(),
salt,
&mut key,
)
.map_err(|e| crate::safety::ic_failed(builtin, e))?,
CryptoKdf::Pbkdf2HmacSha256 => ironcrypto::kdf::pbkdf2::<ironcrypto::mac::HmacSha256>(
password.as_bytes(),
salt,
CRYPTO_PBKDF2_ROUNDS,
&mut key,
)
.map_err(|e| crate::safety::ic_failed(builtin, e))?,
}
Ok(key)
}
fn bi_crypto_encrypt(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
use base64::{engine::general_purpose::STANDARD, Engine as _};
use ironcrypto::core_types::traits::Aead;
let data = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"crypto_encrypt",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let password = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"crypto_encrypt",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
if password.is_empty() {
return Err(crate::safety::arg_err(
"crypto.encrypt requires a non-empty password; nothing was encrypted",
));
}
// Fresh salt per message, so the derived key is unique per ciphertext and a
// random nonce carries no birthday-bound risk across messages.
let mut salt = [0u8; 16];
let mut nonce_bytes = [0u8; 12];
crate::safety::ic_fill_random("crypto_encrypt", &mut salt)?;
crate::safety::ic_fill_random("crypto_encrypt", &mut nonce_bytes)?;
let kdf = CryptoKdf::for_new_ciphertext();
let key = crypto_derive_key("crypto_encrypt", &password, &salt, kdf)?;
// The envelope carries ciphertext then the 16-byte tag, the layout the
// RustCrypto crate produced, so IronCrypto's detached tag is appended here.
let cipher = ironcrypto::cipher::Aes256Gcm::new(&key)
.map_err(|e| crate::safety::ic_failed("crypto_encrypt", e))?;
let mut ct = data.as_bytes().to_vec();
let mut tag = [0u8; 16];
cipher
.seal_detached(&nonce_bytes, kdf.aad(), &mut ct, &mut tag)
.map_err(|_| {
crate::safety::crypto_failed(
"",
"encryption failed; nothing was encrypted",
"retry once; if it fails again the in-process cipher is refusing this input",
)
})?;
ct.extend_from_slice(&tag);
Ok(Value::Str(format!(
"{}.{}.{}.{}",
kdf.tag(),
STANDARD.encode(salt),
STANDARD.encode(nonce_bytes),
STANDARD.encode(&ct)
)))
}
/// Decrypt a pre-AS-2026-04 `openssl enc -aes-256-cbc -pbkdf2` ciphertext. Kept so
/// data written by an older IronShell stays recoverable, but off by default:
/// see the refusal in `bi_crypto_decrypt` for why.
///
/// The format is OpenSSL's `Salted__` envelope, base64 encoded: the magic, an
/// 8-byte salt, then AES-256-CBC with PKCS#7 padding. The key and IV come from
/// PBKDF2-HMAC-SHA256 at 10 000 iterations, 48 bytes, which is what
/// `openssl enc -pbkdf2` does with its defaults (OpenSSL 1.1.1 and later).
/// `openssl` is no longer run; a test decrypts OpenSSL's own output.
const CRYPTO_LEGACY_MAGIC: &[u8] = b"Salted__";
/// PBKDF2 iterations OpenSSL's `enc -pbkdf2` uses when none is given.
const CRYPTO_LEGACY_ITERATIONS: u32 = 10_000;
/// The most plaintext one legacy decrypt may return. Past it the call is
/// refused: a plaintext cut short would look whole.
const CRYPTO_LEGACY_MAX_BYTES: usize = 64 * 1024 * 1024;
fn crypto_decrypt_legacy_cbc(data: &str, password: &str) -> Result<Value> {
use base64::{engine::general_purpose::STANDARD, Engine as _};
use ironcrypto::core_types::traits::BlockCipher as _;
let refused = |why: &str| {
crate::safety::decrypt_failed(
"crypto_decrypt",
format!(
"crypto_decrypt: legacy (unauthenticated) decrypt failed -- the password may be \
wrong, or the ciphertext may have been modified. AES-256-CBC is not authenticated, \
so a *successful* legacy decrypt is not proof of integrity either. {why}"
),
"check the password; if it is right, the ciphertext was altered and should not be \
trusted. Nothing was decrypted",
)
};
let compact: String = data.chars().filter(|c| !c.is_whitespace()).collect();
let raw = STANDARD
.decode(&compact)
.map_err(|_| refused("the input is not base64."))?;
if raw.len() < 16 || &raw[..8] != CRYPTO_LEGACY_MAGIC {
return Err(refused("the input has no `Salted__` header."));
}
let (salt, body) = (&raw[8..16], &raw[16..]);
if body.is_empty() || body.len() % 16 != 0 {
return Err(refused(
"the ciphertext is not a whole number of AES blocks.",
));
}
if body.len() > CRYPTO_LEGACY_MAX_BYTES + 16 {
return Err(refused(
"the ciphertext is larger than the legacy decrypt limit.",
));
}
// 32 bytes of AES-256 key, then the 16-byte CBC IV.
let mut derived = [0u8; 48];
crate::crypto_phc::pbkdf2_sha256_rfc8018(
"crypto_decrypt",
password.as_bytes(),
salt,
CRYPTO_LEGACY_ITERATIONS,
&mut derived,
)?;
let cipher = ironcrypto::cipher::Aes256::new(&derived[..32])
.map_err(|e| crate::safety::ic_failed("crypto_decrypt", e))?;
let mut plain = body.to_vec();
ironcrypto::cipher::cbc_decrypt(&cipher, &derived[32..], &mut plain)
.map_err(|e| crate::safety::ic_failed("crypto_decrypt", e))?;
let n = ironcrypto::cipher::pkcs7_unpad(&plain)
.map_err(|_| refused("the padding is wrong, which is what a bad password gives."))?;
plain.truncate(n);
// The same refusal as the authenticated path: a lossy conversion would
// silently change the plaintext, so non-UTF-8 output is not returned.
String::from_utf8(plain).map(Value::Str).map_err(|_| {
crate::safety::decrypt_failed(
"crypto_decrypt",
"crypto_decrypt: plaintext is not valid UTF-8; the legacy decrypt \
succeeded but its bytes are not text, so nothing was returned",
"the plaintext is binary; the legacy ciphertext has to be decrypted by the \
tool that wrote it",
)
})
}
fn bi_crypto_decrypt(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
use base64::{engine::general_purpose::STANDARD, Engine as _};
use ironcrypto::core_types::traits::Aead;
let data = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"crypto_decrypt",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let password = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"crypto_decrypt",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let trimmed = data.trim();
let parts: Vec<&str> = trimmed.split('.').collect();
let kdf = parts.first().and_then(|t| CryptoKdf::from_tag(t));
if parts.len() != 4 || kdf.is_none() {
// Not an authenticated envelope. Accepting this silently would hand an
// attacker a downgrade: strip the `AE1.` prefix and tag from a modern
// ciphertext and the unauthenticated path would take it. So the legacy
// format is opt-in, by whoever runs the shell rather than by whoever
// supplies the ciphertext.
if !crate::safety::truthy_env("IRONSHELL_CRYPTO_LEGACY_DECRYPT") {
return Err(crate::safety::decrypt_failed(
"crypto_decrypt",
format!(
"crypto_decrypt: this is not an authenticated `{}` ciphertext. It was \
most likely produced by IronShell before 10.0.0, when crypto.encrypt \
used unauthenticated AES-256-CBC (AS-2026-04), so decrypting it cannot \
detect tampering. Nothing was decrypted",
CRYPTO_ENVELOPE_V1
),
"set IRONSHELL_CRYPTO_LEGACY_DECRYPT=1 to decrypt it anyway, then re-encrypt \
with crypto.encrypt to gain integrity protection",
));
}
return crypto_decrypt_legacy_cbc(trimmed, &password);
}
let malformed = |what: &str| {
crate::safety::decrypt_failed(
"crypto_decrypt",
format!("crypto_decrypt: malformed ciphertext ({what}); nothing was decrypted"),
"pass the unmodified output of crypto.encrypt",
)
};
let decode = |what: &str, s: &str| -> Result<Vec<u8>> {
STANDARD
.decode(s)
.map_err(|e| malformed(&format!("{what} is not valid base64: {e}")))
};
let salt = decode("salt", parts[1])?;
let nonce_bytes = decode("nonce", parts[2])?;
let ct = decode("body", parts[3])?;
if nonce_bytes.len() != 12 {
return Err(malformed(&format!(
"nonce is {} bytes, expected 12",
nonce_bytes.len()
)));
}
// The KDF comes from the envelope, never from the ambient FIPS mode. The
// salt is attacker-chosen text, so a salt the KDF refuses is a malformed
// ciphertext, not a shell fault.
let kdf = kdf.expect("checked above");
let key = crypto_derive_key("crypto_decrypt", &password, &salt, kdf).map_err(|e| {
malformed(&format!(
"the key could not be derived from its salt: {}",
strip_code_prefix(&e.to_string())
))
})?;
let cipher = ironcrypto::cipher::Aes256Gcm::new(&key)
.map_err(|e| malformed(&format!("the key has the wrong length: {e}")))?;
// AES-GCM verifies the tag before any plaintext is released, so a wrong
// password and a modified ciphertext give the same answer here -- and
// unlike the CBC path, "it decrypted" now genuinely means "it was not
// modified". The envelope stores the 16-byte tag after the ciphertext.
let auth_failed = || {
crate::safety::decrypt_failed(
"crypto_decrypt",
"crypto_decrypt: authentication failed -- the password is wrong or the \
ciphertext was modified. No plaintext was released",
"check the password; if it is right, the ciphertext was altered and should \
not be trusted",
)
};
let Some(tag_start) = ct.len().checked_sub(16) else {
return Err(auth_failed());
};
let mut plaintext = ct;
let tag = plaintext.split_off(tag_start);
cipher
.open_detached(&nonce_bytes, kdf.aad(), &mut plaintext, &tag)
.map_err(|_| auth_failed())?;
String::from_utf8(plaintext).map(Value::Str).map_err(|_| {
crate::safety::decrypt_failed(
"crypto_decrypt",
"crypto_decrypt: plaintext is not valid UTF-8; nothing was returned",
"the plaintext is binary; crypto.decrypt returns text only",
)
})
}
/// Drop a leading `E_CODE: ` token from an error's text. Used where a shared
/// helper's own code would otherwise appear inside a message that already
/// carries the `E_DECRYPT_FAILED` code.
fn strip_code_prefix(text: &str) -> &str {
match text.split_once(": ") {
Some((head, rest)) if head.starts_with("E_") => rest,
_ => text,
}
}
#[path = "digital_signatures.rs"]
mod digital_signatures;
fn bi_crypto_sign(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
digital_signatures::sign(args, input)
}
fn bi_crypto_key_generate(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let name = "crypto_generate_key";
if args.len() > 2 || input.is_some() {
return Err(crate::safety::arg_err(
"crypto_generate_key accepts zero to two arguments and no pipeline input",
));
}
let key_type = match args.first() {
None => "rsa".to_string(),
Some(Value::Str(s)) => s.to_ascii_lowercase(),
Some(v) => {
return Err(crate::safety::bad_arg(
name,
"key type String",
v.type_name(),
))
}
};
if !matches!(key_type.as_str(), "rsa" | "ec" | "ecdsa" | "ed25519") {
return Err(crate::safety::bad_arg(
name,
"key type rsa, ec, ecdsa or ed25519",
"unsupported key type",
));
}
let bits = match args.get(1) {
None => 2048,
Some(Value::Int(n)) if key_type == "rsa" && matches!(*n, 2048 | 3072 | 4096) => *n,
Some(_) => {
return Err(crate::safety::bad_arg(
name,
"RSA-only integer size of 2048, 3072 or 4096 bits",
"invalid or inapplicable size",
))
}
};
// Generated natively by IronCrypto and returned as unencrypted PKCS#8 PEM.
// No file is written, so there is no file mode to set here; a caller that
// saves the key owns that file's protection.
digital_signatures::generate_key(&key_type, bits)
}
fn password_arguments<'a>(
name: &str,
args: &'a [Value],
input: &Option<Value>,
count: usize,
) -> Result<Vec<&'a str>> {
if input.is_some() || args.len() != count {
return Err(crate::safety::bad_arg(
name,
"exact positional String arguments; no pipeline input",
"invalid call shape",
));
}
let values = args
.iter()
.map(|v| match v {
Value::Str(s) => Ok(s.as_str()),
_ => Err(crate::safety::bad_arg(name, "String", v.type_name())),
})
.collect::<Result<Vec<_>>>()?;
if values[0].len() > 65536 || (count == 2 && values[1].len() > 1024) {
return Err(crate::safety::budget_exceeded(
name,
"password or stored hash exceeds size limit".into(),
"password limit 65536 bytes; stored hash limit 1024 bytes",
));
}
Ok(values)
}
fn bi_crypto_password_hash(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let name = "crypto_password_hash";
let values = password_arguments(name, &args, &input, 1)?;
let mut salt = [0u8; 16];
crate::safety::ic_fill_random(name, &mut salt)?;
let hash = if crate::safety::fips_enabled() {
crate::crypto_phc::pbkdf2_sha256_hash(name, values[0].as_bytes(), &salt)?
} else {
crate::crypto_phc::argon2id_hash(name, values[0].as_bytes(), &salt)?
};
Ok(Value::Str(hash))
}
#[path = "sha_crypt.rs"]
mod sha_crypt;
fn bi_crypto_password_verify(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
use ironcrypto::core_types::ErrorKind;
let name = "crypto_password_verify";
let values = password_arguments(name, &args, &input, 2)?;
let malformed = || {
crate::safety::bad_arg(
name,
"supported password hash with valid parameters",
"invalid stored hash",
)
};
if values[1].len() == 64 && values[1].bytes().all(|b| b.is_ascii_hexdigit()) {
if crate::safety::fips_enabled() {
return Err(crate::safety::policy_deny(
name,
"legacy unsalted password hash",
"rehash with crypto_password_hash",
));
}
// 64 hex digits decode to exactly 32 bytes, the SHA-256 length, so the
// stored and computed digests are compared as the same kind of value.
let expected = hex::decode(values[1]).map_err(|_| malformed())?;
let actual = ic_sha256(name, values[0].as_bytes())?;
return Ok(Value::Bool(ironcrypto::core_types::ct::verify(
&expected, &actual,
)));
}
// SHA-crypt ($5$ / $6$) is verify-only. It is built from SHA-2, but it is not
// an approved password-hashing construction; under IRONSHELL_FIPS only PBKDF2 is,
// as for the legacy hex and Argon2 branches.
if values[1].starts_with("$5$") || values[1].starts_with("$6$") {
if crate::safety::fips_enabled() {
return Err(crate::safety::policy_deny(
name,
"SHA-crypt is outside the configured algorithm policy",
"rehash with crypto_password_hash",
));
}
return Ok(Value::Bool(sha_crypt::verify(name, values[0], values[1])?));
}
// Salt and digest bounds are shared by both formats, and are checked before
// the policy, as they always were.
let bounded = |salt_len: usize, digest_len: usize| {
if (8..=64).contains(&salt_len) && (16..=64).contains(&digest_len) {
Ok(())
} else {
Err(malformed())
}
};
let stored = values[1];
let algorithm = stored
.strip_prefix('$')
.and_then(|rest| rest.split('$').next())
.unwrap_or("");
let verified = match algorithm {
"argon2id" => {
// IronCrypto's parser is the canonical Argon2 PHC reader: it refuses
// padding, non-canonical decimals and unused base64 bits.
let hash = ironcrypto::kdf::argon2::phc::PasswordHash::parse(stored)
.map_err(|_| malformed())?;
bounded(hash.salt.len(), hash.hash.len())?;
if crate::safety::fips_enabled() {
return Err(crate::safety::policy_deny(
name,
"Argon2 is outside the configured algorithm policy",
"use a PBKDF2 password hash",
));
}
// Version 1.3 with the three cost parameters only. A keyid or data
// field is not something this function writes.
if hash.version != ironcrypto::kdf::argon2::Version::V0x13
|| !hash.keyid.is_empty()
|| !hash.data.is_empty()
{
return Err(malformed());
}
let params = hash.params;
if params.memory_kib > 65536 || params.passes > 10 || params.lanes > 4 {
return Err(crate::safety::budget_exceeded(
name,
"stored Argon2 parameters exceed verification limits".into(),
"maximum m=65536 KiB, t=10, p=4",
));
}
match ironcrypto::kdf::argon2::phc::verify_password(values[0].as_bytes(), stored) {
Ok(()) => true,
Err(e) if e.kind() == ErrorKind::AuthenticationFailed => false,
Err(_) => return Err(malformed()),
}
}
"pbkdf2-sha256" => {
let hash = crate::crypto_phc::parse_pbkdf2_sha256(stored).ok_or_else(|| malformed())?;
bounded(hash.salt.len(), hash.hash.len())?;
// The `pbkdf2` crate's defaults stand in for absent fields.
let rounds = hash.rounds.unwrap_or(crate::crypto_phc::PBKDF2_HASH_ROUNDS);
let output_length = hash.output_length.unwrap_or(32);
if rounds == 0 || !(16..=64).contains(&output_length) {
return Err(malformed());
}
if rounds > 1200000 {
return Err(crate::safety::budget_exceeded(
name,
"stored PBKDF2 iterations exceed verification limit".into(),
"maximum 1200000 iterations",
));
}
if output_length != hash.hash.len() {
false
} else {
let derived = crate::crypto_phc::pbkdf2_sha256_verify_derive(
name,
values[0].as_bytes(),
&hash.salt,
rounds,
output_length,
)
.map_err(|_| malformed())?;
ironcrypto::core_types::ct::verify(&derived, &hash.hash)
}
}
_ => return Err(malformed()),
};
Ok(Value::Bool(verified))
}
fn bi_crypto_checksum(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
bi_crypto_hash_file(args, None)
}
fn bi_crypto_cert_info(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let name = "crypto_cert_info";
if args.len() != 1 || input.is_some() {
return Err(crate::safety::arg_err(
"crypto_cert_info requires one path and no pipeline input",
));
}
let Value::Str(path) = &args[0] else {
return Err(crate::safety::bad_arg(
name,
"path String",
args[0].type_name(),
));
};
crate::safety::reject_option_like(name, std::slice::from_ref(path))?;
let path = crate::safety::resolve_path_str(path);
let metadata =
std::fs::metadata(&path).map_err(|e| crate::safety::fs_error(name, &path, &e))?;
if !metadata.is_file() {
return Err(crate::safety::bad_arg(
name,
"regular certificate file",
"non-file path",
));
}
if metadata.len() > 64 * 1024 * 1024 {
return Err(crate::safety::budget_exceeded(
name,
"certificate input exceeds 64 MiB".into(),
"inspect a smaller file",
));
}
// IronSocketLayer's X.509 (IronPKI) parses the certificates in-process; no
// openssl process is started. See src/certificates.rs.
let bytes = std::fs::read(&path).map_err(|e| crate::safety::fs_error(name, &path, &e))?;
Ok(Value::Array(crate::certificates::inspect(name, &bytes)?))
}
// ============================================================================
// 500-519: Database
// ============================================================================
fn sqlite_db_exists(path: &str) -> bool {
path == ":memory:" || std::path::Path::new(path).exists()
}
/// Refuses a database path that names an existing file which is not an SQLite
/// database. sqlite3 3.53 reads such a file as a SQL script: given
/// `sqlite3 notes.txt "VACUUM"` it ran notes.txt as SQL, opened a database
/// named `VACUUM` in the working directory, and exited 0, so a builtin handed
/// the wrong file executed it and answered true. An absent path (sqlite3
/// creates it), `:memory:`, an empty file (an empty database) and a file that
/// starts with the SQLite header pass.
fn sqlite_db_file(builtin: &str, path: &str) -> Result<()> {
if path == ":memory:" {
return Ok(());
}
let mut file = match std::fs::File::open(path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
// A directory, or one sqlite3 cannot open either: its own error says so.
Err(_) => return Ok(()),
};
if std::fs::metadata(path).map(|m| m.is_dir()).unwrap_or(false) {
return Ok(());
}
let mut head = [0u8; 16];
let mut got = 0;
while got < head.len() {
match std::io::Read::read(&mut file, &mut head[got..]) {
Ok(0) => break,
Ok(n) => got += n,
Err(e) => return Err(crate::safety::fs_error(builtin, path, &e)),
}
}
if got == 0 || (got == head.len() && &head == b"SQLite format 3\0") {
return Ok(());
}
Err(crate::safety::bad_arg(
builtin,
"an SQLite database file, or a path that does not exist yet",
&format!("{path}, which is not an SQLite database"),
))
}
fn bi_db_sqlite_query(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
// In-process and read-only since this commit; see src/sql.rs. It spawned
// the sqlite3 CLI, so it was classed Exec, gated in agent mode, and absent
// on any host without sqlite3.
crate::sql::sql("sql", args, input)
}
/// explain_effects(code): every call in `code`, its effect class and the
/// agent-mode decision, without running it; see src/explain.rs.
fn bi_explain_effects(args: Vec<Value>) -> Result<Value> {
match args.first() {
Some(Value::Str(code)) => crate::explain::explain(code),
other => Err(crate::safety::bad_arg(
"explain_effects",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
)),
}
}
/// sql_value: the one cell of a one-row, one-column query; see src/sql.rs.
fn bi_sql_value(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
crate::sql::sql_value(args, input)
}
/// The workspace jail for the database every sqlite writer changes, applied in
/// `db_sqlite_exec`, which they all reach. Those writers are WriteLocal, which
/// the dispatcher does not jail, and none guarded its database: in agent mode
/// `db_sqlite_insert` wrote a database outside the workspace that `file_write`
/// was refused for.
///
/// A helper rather than a guard call in the body, on purpose: a body that
/// guards is listed in SELF_GUARDED and skipped by the dispatcher, and
/// `db_sqlite_exec` called directly is Exec, which must keep the dispatcher's
/// approval. The dispatcher does not jail Exec, so the two checks do not overlap.
fn sqlite_db_in_workspace(db_path: &str) -> Result<String> {
guard_local_write("db_sqlite_exec", db_path)
}
fn bi_db_sqlite_exec(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let (db_path, sql) = sqlite_exec_prepare(args)?;
// Every mutating sqlite builtin (insert/update/delete/create_table/drop_table)
// routes through here, so snapshotting the db file at this single chokepoint
// makes those mutations reversible inside a transaction. No-op when no
// transaction is active.
crate::tx::snapshot(&db_path);
sqlite_exec_run(&db_path, &sql, false)
}
/// Runs a statement that a builtin of its own built and validated. The sqlite3
/// CLI gets `-safe`, which refuses writefile(), ATTACH and the file
/// dot-commands, so a `;` the builtin missed still cannot reach the filesystem.
/// `db_sqlite_exec` takes arbitrary SQL, is classed Exec and asks for approval,
/// so it does not go through here.
fn sqlite_exec_contained(args: Vec<Value>) -> Result<Value> {
let (db_path, sql) = sqlite_exec_prepare(args)?;
crate::tx::snapshot(&db_path);
sqlite_exec_run(&db_path, &sql, true)
}
/// The arguments of a statement run, refused before anything is opened: a
/// dot-command, an option-like path, and a path outside the workspace.
fn sqlite_exec_prepare(args: Vec<Value>) -> Result<(String, String)> {
let db_path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_exec",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let sql = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_exec",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
crate::safety::reject_sqlite_dot_command("db_sqlite_exec", &sql)?;
crate::safety::reject_option_like("db_sqlite_exec", std::slice::from_ref(&db_path))?;
let db_path = sqlite_db_in_workspace(&db_path)?;
Ok((db_path, sql))
}
fn sqlite_exec_run(db_path: &str, sql: &str, safe: bool) -> Result<Value> {
// Checked again where the path is handed to the CLI, which is where it matters.
crate::safety::reject_option_like("db_sqlite_exec", &[db_path.to_string()])?;
sqlite_db_file("db_sqlite_exec", db_path)?;
let mut cmd = crate::safety::child_command("sqlite3");
if safe {
cmd.arg("-safe");
}
cmd.args([db_path, sql]);
let output = bounded_cmd_output("db_sqlite_exec", "sqlite3", cmd, TOOL_DEADLINE_SECS)?;
// Every mutating sqlite builtin comes through here, and a SQL error was a
// bare `false` with sqlite3's message discarded.
if !output.status.success() {
return Err(crate::safety::tool_failed(
"db_sqlite_exec",
"sqlite3",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
Ok(Value::Bool(true))
}
fn bi_db_sqlite_tables(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let db_path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_tables",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
bi_db_sqlite_query(
vec![
Value::Str(db_path),
Value::Str(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name".to_string(),
),
],
None,
)
}
fn bi_db_sqlite_schema(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let db_path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_schema",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let table = args
.get(1)
.map(|v| expect_string("db_sqlite_schema", v))
.transpose()?;
let query = if let Some(t) = table {
format!(
"SELECT * FROM pragma_table_info({})",
crate::safety::sql_literal("db_sqlite_schema", &t)?
)
} else {
"SELECT sql FROM sqlite_master WHERE type='table' ORDER BY name".to_string()
};
bi_db_sqlite_query(vec![Value::Str(db_path), Value::Str(query)], None)
}
fn bi_db_sqlite_create(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let db_path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_create",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
crate::safety::reject_option_like("db_sqlite_create", std::slice::from_ref(&db_path))?;
// Opening creates the file, so it is jailed as a write.
let db_path = guard_local_write("db_sqlite_create", &db_path)?;
// Just touch the file to create empty database
sqlite_db_file("db_sqlite_create", &db_path)?;
let mut cmd = crate::safety::child_command("sqlite3");
cmd.args(["-safe", &db_path, "SELECT 1"]);
let output = bounded_cmd_output("db_sqlite_create", "sqlite3", cmd, TOOL_DEADLINE_SECS)?;
if !output.status.success() {
return Err(crate::safety::tool_failed(
"db_sqlite_create",
"sqlite3",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
Ok(Value::Bool(true))
}
fn bi_db_sqlite_backup(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let db_path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_backup",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let backup_path = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
None => format!("{}.backup", db_path),
Some(other) => {
return Err(crate::safety::bad_arg(
"db_sqlite_backup",
"destination: String",
other.type_name(),
))
}
};
crate::safety::reject_option_like("db_sqlite_backup", std::slice::from_ref(&db_path))?;
// The path is interpolated into a `.backup '…'` dot-command. A quote in it
// closes that argument and the rest becomes further arguments to `.backup`
// — measured: `.backup 'x' --append 'other.db'` reaches the option parser.
// That is not command execution (`.backup`'s options do not run programs,
// and a newline does not start a second dot-command when the whole string
// arrives as one argv entry — both checked against sqlite3 3.53.4), but it
// does let a caller who asked to write one file pass options to a command
// they were not given. Refused rather than escaped: a backup path with a
// quote or a newline in it is not a request worth honouring.
if backup_path.contains('\'') || backup_path.contains('\n') {
return Err(crate::safety::bad_arg(
"db_sqlite_backup",
"a backup path without quotes or newlines",
&backup_path,
));
}
// The backup is the file written; the database is only read.
let backup_path = guard_local_write("db_sqlite_backup", &backup_path)?;
// sqlite3 opens an absent source by creating it, so this exited 0, wrote an
// empty backup and left an empty database at the source path. `:memory:` is
// equally empty in a new sqlite3 process.
if db_path == ":memory:" {
return Err(crate::safety::bad_arg(
"db_sqlite_backup",
"a database file; :memory: holds nothing in a new sqlite3 process",
&db_path,
));
}
if !sqlite_db_exists(&db_path) {
return Err(crate::safety::not_found(
"db_sqlite_backup",
"database",
&db_path,
));
}
crate::tx::snapshot(&backup_path);
sqlite_db_file("db_sqlite_backup", &db_path)?;
sqlite_vacuum_into("db_sqlite_backup", &db_path, &backup_path)?;
Ok(Value::Bool(true))
}
/// Writes a copy of `db_path` to `dest` in-process with `VACUUM INTO`. This
/// replaces the sqlite3 CLI's `.backup` dot-command: `-safe` refuses `.backup`,
/// and here the destination is a bound parameter, not text inside a dot-command.
/// The copy is staged beside `dest` and renamed over it once whole, so a failed
/// backup leaves an existing one as it was.
fn sqlite_vacuum_into(builtin: &str, db_path: &str, dest: &str) -> Result<()> {
crate::tx::snapshot(dest);
let staged = format!("{dest}.part-{}", std::process::id());
let _ = std::fs::remove_file(&staged);
let copied = (|| -> Result<()> {
let conn = rusqlite::Connection::open_with_flags(
db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
)
.map_err(|e| crate::safety::tool_failed(builtin, "VACUUM INTO", None, &e.to_string()))?;
conn.execute("VACUUM INTO ?1", [staged.as_str()])
.map_err(|e| {
crate::safety::tool_failed(builtin, "VACUUM INTO", None, &e.to_string())
})?;
std::fs::rename(&staged, dest).map_err(|e| crate::safety::fs_error(builtin, dest, &e))
})();
if copied.is_err() {
let _ = std::fs::remove_file(&staged);
}
copied
}
/// A coded budget refusal whose builtin name is left empty: the boundary
/// (`ensure_structured`) fills it in, so the message carries no builtin prefix.
fn budget_unnamed(message: &str, hint: &str) -> anyhow::Error {
anyhow::Error::new(crate::safety::SafetyError {
code: crate::safety::ErrorCode::BudgetExceeded,
message: message.to_string(),
builtin: String::new(),
hint: hint.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
})
}
/// A coded I/O failure from a library whose error is not an `io::Error` (here
/// rusqlite), so `safety::fs_error` does not apply. `E_IO` is the code for a
/// failure that is neither absence nor refusal.
fn coded_io(builtin: &str, message: String, hint: &str) -> anyhow::Error {
anyhow::Error::new(crate::safety::SafetyError {
code: crate::safety::ErrorCode::Io,
message,
builtin: builtin.to_string(),
hint: hint.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
})
}
fn bi_db_csv_to_sqlite(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
const NAME: &str = "db_csv_to_sqlite";
let db_path = args
.first()
.ok_or_else(|| crate::safety::arg_err("db_csv_to_sqlite: db is required"))?;
let db_path = expect_string(NAME, db_path)?;
let csv_path = args
.get(1)
.ok_or_else(|| crate::safety::arg_err("db_csv_to_sqlite: csv is required"))?;
let csv_path = expect_string(NAME, csv_path)?;
let table = args
.get(2)
.map(|v| expect_string(NAME, v))
.transpose()?
.unwrap_or("imported");
let table = format!("\"{}\"", crate::safety::sql_identifier(NAME, table)?);
let csv_path = crate::safety::resolve_path_str(csv_path);
std::fs::metadata(&csv_path).map_err(|e| crate::safety::fs_error(NAME, &csv_path, &e))?;
let source = crate::security::validate_read_path(&csv_path).map_err(|e| {
crate::safety::policy_deny(
NAME,
format!("{NAME}: {e}"),
"the shell's read policy refuses this file; choose a file it permits",
)
})?;
let metadata =
std::fs::metadata(&source).map_err(|e| crate::safety::fs_error(NAME, &csv_path, &e))?;
crate::security::check_file_size_limit(metadata.len()).map_err(|e| {
crate::safety::budget_exceeded(
NAME,
e.to_string(),
"the CSV file is larger than the read limit; split it into smaller files",
)
})?;
let content = std::fs::read_to_string(&source)
.map_err(|e| crate::safety::fs_error(NAME, &csv_path, &e))?;
let csv_error = |e: csv::Error| {
crate::safety::bad_arg(
NAME,
"CSV with a header and equal-length rows",
&e.to_string(),
)
};
let mut reader = csv::ReaderBuilder::new()
.trim(csv::Trim::Headers)
.from_reader(content.as_bytes());
let headers = reader.headers().map_err(csv_error)?.clone();
if headers.is_empty() {
return Err(crate::safety::arg_err(
"db_csv_to_sqlite: CSV header must be nonempty",
));
}
let mut names = std::collections::HashSet::new();
let columns: Vec<String> = headers
.iter()
.map(|name| {
let name = crate::safety::sql_identifier(NAME, name)?;
if !names.insert(name.to_ascii_lowercase()) {
return Err(crate::safety::arg_err(
"db_csv_to_sqlite: duplicate CSV column name",
));
}
Ok(format!("\"{name}\""))
})
.collect::<Result<_>>()?;
// Parse all rows before opening the destination; failed validation creates nothing.
let rows = reader
.records()
.enumerate()
.map(|(index, row)| {
if index >= 1_000_000 {
return Err(crate::safety::budget_exceeded(
NAME,
"CSV exceeds 1000000 rows".to_string(),
"split the CSV into files of at most 1000000 rows",
));
}
row.map_err(csv_error)
})
.collect::<Result<Vec<_>>>()?;
let db_path = guard_local_write(NAME, db_path)?;
crate::tx::snapshot(&db_path);
let mut db = rusqlite::Connection::open(&db_path).map_err(|e| {
coded_io(
NAME,
format!("{NAME}: cannot open the database: {e}"),
"check that the database path is writable and not locked by another process",
)
})?;
let transaction = db.transaction().map_err(|e| {
coded_io(
NAME,
format!("{NAME}: cannot begin the import transaction: {e}"),
"no rows were written; check that the database is not locked or corrupt",
)
})?;
let sql_error = |e: rusqlite::Error| {
crate::safety::arg_err(&format!(
"{NAME}: SQLite rejected import: {e}; no rows committed"
))
};
let definitions = columns
.iter()
.map(|name| format!("{name} TEXT"))
.collect::<Vec<_>>()
.join(", ");
transaction
.execute_batch(&format!(
"CREATE TABLE IF NOT EXISTS {table} ({definitions})"
))
.map_err(sql_error)?;
let placeholders = vec!["?"; columns.len()].join(", ");
{
let mut statement = transaction
.prepare(&format!(
"INSERT INTO {table} ({}) VALUES ({placeholders})",
columns.join(", ")
))
.map_err(sql_error)?;
for row in rows {
statement
.execute(rusqlite::params_from_iter(row.iter()))
.map_err(sql_error)?;
}
}
transaction
.commit()
.map_err(|e| {
coded_io(
NAME,
format!("{NAME}: committing import: {e}"),
"the import was rolled back and no rows were committed; check that the database is writable and has space",
)
})?;
Ok(Value::Bool(true))
}
fn bi_db_sqlite_to_csv(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let db_path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_to_csv",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let table_or_query = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_to_csv",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
// The output was written with a bare fs::write: unjailed, not
// journalled, and truncated in place before the new text landed. It is
// now refused before sqlite3 runs if the jail forbids it, snapshotted for
// a transaction, and published atomically, as session_export does.
let output_path = args
.get(2)
.map(|v| expect_string("db_sqlite_to_csv", v))
.transpose()?
.map(|p| guard_local_write("db_sqlite_to_csv", p))
.transpose()?;
// A leading SELECT means the caller passed a query and owns it, the same
// contract as the WHERE clause in `db_sqlite_delete`. Anything else is a
// table *name*, so it is an identifier and is validated — without this it
// was interpolated raw, and the sqlite3 CLI runs every `;`-separated
// statement it is handed.
let query = if table_or_query.to_uppercase().starts_with("SELECT") {
table_or_query
} else {
format!(
"SELECT * FROM {}",
crate::safety::sql_identifier("db_sqlite_to_csv", &table_or_query)?
)
};
crate::safety::reject_sqlite_dot_command("db_sqlite_to_csv", &query)?;
crate::safety::reject_option_like("db_sqlite_to_csv", std::slice::from_ref(&db_path))?;
// sqlite3 creates an absent database, so an export would leave one behind.
if !sqlite_db_exists(&db_path) {
return Err(crate::safety::not_found(
"db_sqlite_to_csv",
"database",
&db_path,
));
}
sqlite_db_file("db_sqlite_to_csv", &db_path)?;
let mut cmd = crate::safety::child_command("sqlite3");
// -readonly: a SELECT that carries a second statement cannot change the file.
cmd.args(["-safe", "-readonly", "-header", "-csv", &db_path, &query]);
let output = bounded_cmd_output("db_sqlite_to_csv", "sqlite3", cmd, TOOL_DEADLINE_SECS)?;
if output.status.success() {
let csv = String::from_utf8_lossy(&output.stdout).to_string();
if let Some(path) = output_path {
crate::tx::snapshot(&path);
write_file_atomically("db_sqlite_to_csv", &path, csv.as_bytes())?;
return Ok(Value::Str(path));
}
return Ok(Value::Str(csv));
}
Err(crate::safety::tool_failed(
"db_sqlite_to_csv",
"sqlite3",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
))
}
fn bi_db_kv_get(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let store_path = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => ".ironshell_kv.db".to_string(),
};
let key = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_kv_get",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
// A store that does not exist yet holds no value; asking must not
// create one.
if !sqlite_db_exists(&store_path) {
return Ok(Value::Null);
}
bi_db_sqlite_query(
vec![
Value::Str(store_path),
Value::Str(format!(
"SELECT value FROM kv WHERE key = {}",
crate::safety::sql_literal("db_kv_get", &key)?
)),
],
None,
)
.map(|v| match v {
Value::Array(arr) if !arr.is_empty() => {
if let Value::Record(rec) = &arr[0] {
rec.get("value").cloned().unwrap_or(Value::Null)
} else {
Value::Null
}
}
_ => Value::Null,
})
}
fn bi_db_kv_set(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let store_path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_kv_set",
"store: String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let key = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_kv_set",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let value = match args.get(2) {
Some(Value::Str(s)) => s.clone(),
Some(v) => format!("{:?}", v),
other => {
return Err(crate::safety::bad_arg(
"db_kv_set",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
// Validate literals before any file can be created or changed.
let query = format!(
"INSERT OR REPLACE INTO kv (key, value) VALUES ({}, {})",
crate::safety::sql_literal("db_kv_set", &key)?,
crate::safety::sql_literal("db_kv_set", &value)?
);
// Ensure table exists, propagating its failure.
sqlite_exec_contained(vec![
Value::Str(store_path.clone()),
Value::Str("CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT)".to_string()),
])?;
sqlite_exec_contained(vec![Value::Str(store_path), Value::Str(query)])
}
fn bi_db_kv_delete(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let store_path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_kv_delete",
"store: String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let key = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_kv_delete",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
crate::safety::guard(crate::safety::GuardCtx {
builtin: "db_kv_delete",
effect: crate::safety::Effect::Destructive,
what: "db_delete",
targets: vec![format!("{}:{}", store_path, key)],
blast_radius: serde_json::json!({ "store": store_path.clone(), "key": key.clone() }),
reversible: false,
fs_paths: false,
})?;
sqlite_exec_contained(vec![
Value::Str(store_path),
Value::Str(format!(
"DELETE FROM kv WHERE key = {}",
crate::safety::sql_literal("db_kv_delete", &key)?
)),
])
}
fn bi_db_kv_keys(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let store_path = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => ".ironshell_kv.db".to_string(),
};
if !sqlite_db_exists(&store_path) {
return Ok(Value::Array(vec![]));
}
bi_db_sqlite_query(
vec![
Value::Str(store_path),
Value::Str("SELECT key FROM kv".to_string()),
],
None,
)
.map(|v| match v {
Value::Array(arr) => Value::Array(
arr.iter()
.filter_map(|r| match r {
Value::Record(rec) => rec.get("key").cloned(),
_ => None,
})
.collect(),
),
_ => Value::Array(vec![]),
})
}
/// Deadline for one `db_json_query` run of jq over a file.
const DB_JQ_DEADLINE_SECS: u64 = 30;
/// The most output one `db_json_query` may produce. More is E_BUDGET_EXCEEDED.
const DB_JQ_MAX_OUTPUT: usize = 16 * 1024 * 1024;
fn bi_db_json_query(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let json_path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_json_query",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let jq_filter = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
_ => ".".to_string(),
};
// Both go to jq positionally, so a leading `-` would be read as an option.
crate::safety::reject_option_like("db_json_query", &[json_path.clone(), jq_filter.clone()])?;
// jq reads the file itself and gets no stdin, so nothing is written to it
// here. The run has a deadline and an output cap like every other tool run.
let output = run_output(
"db_json_query",
"jq",
"jq",
&[jq_filter.as_str(), json_path.as_str()],
|_| {},
None,
DB_JQ_DEADLINE_SECS,
DB_JQ_MAX_OUTPUT,
)?;
if output.status.success() {
let json_str = String::from_utf8_lossy(&output.stdout);
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&json_str) {
return Ok(json_to_value(json));
}
return Ok(Value::Str(json_str.trim().to_string()));
}
// This "fell back" to reading the file and returning all of it, so a
// filter jq rejected answered with the whole document, filter ignored.
Err(crate::safety::tool_failed(
"db_json_query",
"jq",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
))
}
fn bi_db_csv_query(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let csv_path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_csv_query",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let content = read_text_capped("db_csv_query", &csv_path)?;
let mut reader = csv::ReaderBuilder::new()
.trim(csv::Trim::All)
.from_reader(content.as_bytes());
let csv_error = |e: csv::Error| {
crate::safety::bad_arg("db_csv_query", "CSV with equal-length rows", &e.to_string())
};
let headers = reader.headers().map_err(csv_error)?.clone();
let rows = reader
.records()
.map(|record| {
let record = record.map_err(csv_error)?;
Ok(Value::Record(
headers
.iter()
.zip(record.iter())
.map(|(header, value)| (header.to_string(), Value::Str(value.to_string())))
.collect(),
))
})
.collect::<Result<Vec<_>>>()?;
Ok(Value::Array(rows))
}
fn bi_db_json_to_csv(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// Every failure here answered "": invalid JSON, a non-array, a first row
// that was not a record. Worse, cells that were not strings were written
// with Rust's Debug format, so 5 became `Int(5)`; columns came from the
// first record only, so a field first seen later was dropped; and a row
// that was not a record was skipped.
const B: &str = "db_json_to_csv";
let rows = match args.first() {
Some(Value::Array(arr)) => arr.clone(),
Some(Value::Str(s)) => {
let json = serde_json::from_str::<serde_json::Value>(s)
.map_err(|e| crate::safety::bad_arg(B, "valid JSON", &e.to_string()))?;
match json_to_value(json) {
Value::Array(arr) => arr,
other => {
return Err(crate::safety::bad_arg(
B,
"a JSON array of objects",
other.type_name(),
))
}
}
}
other => {
return Err(crate::safety::bad_arg(
B,
"an array of records, or its JSON text",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
if rows.is_empty() {
return Ok(Value::Str(String::new()));
}
let mut headers = std::collections::BTreeSet::new();
for row in &rows {
match row {
Value::Record(rec) => headers.extend(rec.keys().cloned()),
other => {
return Err(crate::safety::bad_arg(
B,
"every row a record",
other.type_name(),
))
}
}
}
let headers: Vec<String> = headers.into_iter().collect();
fn cell(v: Option<&Value>) -> String {
let raw = match v {
None | Some(Value::Null) => return String::new(),
Some(Value::Str(s)) => s.clone(),
Some(v @ (Value::Int(_) | Value::Float(_) | Value::Bool(_))) => v.to_display_string(),
Some(other) => value_to_json(other.clone()).to_string(),
};
if raw.contains([',', '"', '\n', '\r']) {
format!("\"{}\"", raw.replace('"', "\"\""))
} else {
raw
}
}
let mut csv = headers
.iter()
.map(|h| cell(Some(&Value::Str(h.clone()))))
.collect::<Vec<_>>()
.join(",");
csv.push('\n');
for row in &rows {
let Value::Record(rec) = row else {
unreachable!("checked above")
};
let line: Vec<String> = headers.iter().map(|h| cell(rec.get(h))).collect();
csv.push_str(&line.join(","));
csv.push('\n');
}
Ok(Value::Str(csv))
}
fn bi_db_csv_to_json(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
bi_db_csv_query(args, None)
}
// ============================================================================
// Missing Clipboard & Input implementations
// ============================================================================
/// Most image data `clipboard_get_image` returns or `clipboard_set_image` copies.
const CLIPBOARD_IMAGE_MAX: usize = 32 * 1024 * 1024;
/// The image types the clipboard image builtins carry, in the order
/// `clipboard_get_image` prefers them.
#[cfg(target_os = "linux")]
const CLIPBOARD_IMAGE_TYPES: [&str; 5] = [
"image/png",
"image/jpeg",
"image/gif",
"image/bmp",
"image/webp",
];
/// The MIME type of an image, from its first bytes, if it is one the
/// clipboard image builtins carry.
fn image_mime_of(bytes: &[u8]) -> Option<&'static str> {
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
Some("image/png")
} else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
Some("image/jpeg")
} else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
Some("image/gif")
} else if bytes.starts_with(b"BM") && bytes.len() > 14 {
Some("image/bmp")
} else if bytes.len() > 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
Some("image/webp")
} else {
None
}
}
/// The targets (X11) or MIME types (Wayland) the Linux clipboard offers, one
/// per entry; empty when nothing owns the clipboard. With no display it is
/// E_NO_UI. xsel cannot list targets or carry images, so only xclip serves
/// X11 here.
#[cfg(target_os = "linux")]
fn linux_clipboard_offers(name: &str) -> Result<Vec<String>> {
if !display_env_set("DISPLAY") && !display_env_set("WAYLAND_DISPLAY") {
return Err(crate::safety::no_ui(name));
}
let lines = |out: &std::process::Output| {
String::from_utf8_lossy(&out.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
};
if display_env_set("WAYLAND_DISPLAY") {
let out = linux_clipboard_tool(name, "wl-paste", &["--list-types"])?.ok_or_else(|| {
crate::safety::tool_missing(
name,
"wl-paste",
"install wl-clipboard to use clipboard images",
)
})?;
if out.status.success() {
return Ok(lines(&out));
}
if out.status.code() == Some(1)
&& String::from_utf8_lossy(&out.stderr).contains("Nothing is copied")
{
return Ok(Vec::new());
}
return Err(crate::safety::tool_failed(
name,
"wl-paste",
out.status.code(),
&gui_stderr_tail(&out),
));
}
let out = linux_clipboard_tool(
name,
"xclip",
&["-selection", "clipboard", "-o", "-t", "TARGETS"],
)?
.ok_or_else(|| {
crate::safety::tool_missing(
name,
"xclip",
"install xclip to use clipboard images; xsel cannot carry them",
)
})?;
if out.status.success() {
return Ok(lines(&out));
}
if String::from_utf8_lossy(&out.stderr).contains("not available") {
return Ok(Vec::new());
}
Err(crate::safety::tool_failed(
name,
"xclip",
out.status.code(),
&gui_stderr_tail(&out),
))
}
/// The Record `clipboard_get_image` answers with, from the image bytes a
/// platform clipboard gave. The refusals are the same on every OS: over
/// `CLIPBOARD_IMAGE_MAX` is E_BUDGET_EXCEEDED, and no data is E_NOT_FOUND.
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
fn clipboard_image_answer(name: &str, mime: &str, bytes: &[u8]) -> Result<Value> {
if bytes.len() > CLIPBOARD_IMAGE_MAX {
return Err(crate::safety::budget_exceeded(
name,
format!(
"the clipboard image is {} bytes, over the {CLIPBOARD_IMAGE_MAX}-byte limit",
bytes.len()
),
"copy a smaller image; this shell returns no truncated image",
));
}
if bytes.is_empty() {
return Err(crate::safety::not_found(
name,
"image on the clipboard",
"the clipboard offered an image but gave no data; copy it again",
));
}
use base64::Engine;
let mut rec = BTreeMap::new();
rec.insert("mime".to_string(), Value::Str(mime.to_string()));
rec.insert("bytes".to_string(), Value::Int(bytes.len() as i64));
rec.insert(
"base64".to_string(),
Value::Str(base64::engine::general_purpose::STANDARD.encode(bytes)),
);
Ok(Value::Record(rec))
}
/// The image `clipboard_get_image` refuses with E_NOT_FOUND when the clipboard
/// holds none. Shared by every platform so the code and text match.
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
fn no_clipboard_image(name: &str) -> anyhow::Error {
crate::safety::not_found(
name,
"image on the clipboard",
"the clipboard holds no PNG, JPEG, GIF, BMP or WEBP image",
)
}
/// The PowerShell script `clipboard_get_image` runs on Windows, under -STA (the
/// Clipboard class needs a single-threaded apartment). It writes `NONE` when
/// the clipboard holds no image, `OVER` when the PNG is past the cap (checked
/// before it is turned into text, so a huge image is never encoded), and
/// otherwise the PNG as base64 on stdout. Nothing is interpolated: the script
/// is fixed, and LIMIT is replaced by the number of CLIPBOARD_IMAGE_MAX.
#[cfg(target_os = "windows")]
const WINDOWS_IMAGE_GET_SCRIPT: &str = r#"$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Windows.Forms, System.Drawing
$img = $null
$done = $false
for ($try = 0; $try -lt 10 -and -not $done; $try++) {
try {
if ([System.Windows.Forms.Clipboard]::ContainsImage()) { $img = [System.Windows.Forms.Clipboard]::GetImage() }
$done = $true
} catch { Start-Sleep -Milliseconds 100 }
}
if (-not $done) { throw 'the clipboard stayed busy' }
if ($null -eq $img) {
'NONE'
} else {
$png = New-Object System.IO.MemoryStream
$img.Save($png, [System.Drawing.Imaging.ImageFormat]::Png)
if ($png.Length -gt LIMIT) { 'OVER' } else { [Convert]::ToBase64String($png.ToArray()) }
}"#;
/// The PowerShell script `clipboard_set_image` runs on Windows, under -STA. The
/// image arrives on stdin, never in the script text. It is copied as a bitmap
/// and as PNG, and flushed with copy=true so it outlives this process.
#[cfg(target_os = "windows")]
const WINDOWS_IMAGE_SET_SCRIPT: &str = r#"$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Windows.Forms, System.Drawing
$in = New-Object System.IO.MemoryStream
[Console]::OpenStandardInput().CopyTo($in)
$in.Position = 0
$src = [System.Drawing.Image]::FromStream($in)
$bmp = New-Object System.Drawing.Bitmap $src
$src.Dispose()
$png = New-Object System.IO.MemoryStream
$bmp.Save($png, [System.Drawing.Imaging.ImageFormat]::Png)
$pngBytes = $png.ToArray()
$data = New-Object System.Windows.Forms.DataObject
$data.SetData([System.Windows.Forms.DataFormats]::Bitmap, $bmp)
$data.SetData('PNG', $false, [System.IO.MemoryStream]::new($pngBytes))
$done = $false
for ($try = 0; $try -lt 10 -and -not $done; $try++) {
try { [System.Windows.Forms.Clipboard]::SetDataObject($data, $true); $done = $true }
catch { Start-Sleep -Milliseconds 100 }
}
if (-not $done) { throw 'the clipboard stayed busy' }"#;
/// `clipboard_get_image` on Windows: the PNG the script copies out of the
/// clipboard, through the shared answer.
#[cfg(target_os = "windows")]
fn windows_clipboard_image(name: &str) -> Result<Value> {
use base64::Engine;
let script = WINDOWS_IMAGE_GET_SCRIPT.replace("LIMIT", &CLIPBOARD_IMAGE_MAX.to_string());
let mut cmd = crate::safety::child_command("powershell");
cmd.args([
"-NoProfile",
"-NonInteractive",
"-STA",
"-Command",
script.as_str(),
]);
let output = bounded_cmd_output(name, "powershell", cmd, TOOL_DEADLINE_SECS)?;
if !output.status.success() {
return Err(crate::safety::tool_failed(
name,
"powershell",
output.status.code(),
&gui_stderr_tail(&output),
));
}
let answer = String::from_utf8_lossy(&output.stdout);
match answer.trim() {
"NONE" => Err(no_clipboard_image(name)),
"OVER" => Err(crate::safety::budget_exceeded(
name,
format!("the clipboard image is over the {CLIPBOARD_IMAGE_MAX}-byte limit"),
"copy a smaller image; this shell returns no truncated image",
)),
encoded => {
let bytes = base64::engine::general_purpose::STANDARD
.decode(encoded)
.map_err(|e| {
crate::safety::tool_failed(
name,
"powershell",
Some(0),
&format!("its answer was not base64: {e}"),
)
})?;
clipboard_image_answer(name, "image/png", &bytes)
}
}
}
/// `clipboard_set_image` on Windows: the validated image bytes go to the script
/// on stdin. The script's own failure is the tool's failure.
#[cfg(target_os = "windows")]
fn windows_clipboard_set_image(name: &str, bytes: Vec<u8>) -> Result<()> {
let args = [
"-NoProfile",
"-NonInteractive",
"-STA",
"-Command",
WINDOWS_IMAGE_SET_SCRIPT,
];
let output = run_output(
name,
"powershell",
"powershell",
&args,
|_| {},
Some(bytes),
TOOL_DEADLINE_SECS,
64 * 1024,
)?;
if output.status.success() {
return Ok(());
}
Err(crate::safety::tool_failed(
name,
"powershell",
output.status.code(),
&gui_stderr_tail(&output),
))
}
/// The AppleScript `clipboard_get_image` runs on macOS, with the output path as
/// its one argument. A clipboard with no PNG class (error -1700) answers NONE;
/// any other error is reported as the failure it is. OK means the PNG is in the
/// file. AppleScript cannot measure PNG data (`length of` it is error -1728), so
/// the size is checked from the file before it is read.
#[cfg(target_os = "macos")]
const MACOS_IMAGE_GET_SCRIPT: &str = r#"on run argv
set outPath to item 1 of argv
try
set pngData to the clipboard as «class PNGf»
on error errMsg number errNum
if errNum = -1700 then return "NONE"
error errMsg number errNum
end try
set fh to open for access (POSIX file outPath) with write permission
try
set eof of fh to 0
write pngData to fh
close access fh
on error errMsg number errNum
try
close access fh
end try
error errMsg number errNum
end try
return "OK"
end run"#;
/// The AppleScript `clipboard_set_image` runs on macOS. Arguments: the absolute
/// image path, then its kind (png, jpeg, gif or bmp), which picks the AppleScript
/// class the file is read as.
#[cfg(target_os = "macos")]
const MACOS_IMAGE_SET_SCRIPT: &str = r#"on run argv
set inPath to item 1 of argv
set imageKind to item 2 of argv
if imageKind = "png" then
set imageData to read (POSIX file inPath) as «class PNGf»
else if imageKind = "jpeg" then
set imageData to read (POSIX file inPath) as «class JPEG»
else if imageKind = "gif" then
set imageData to read (POSIX file inPath) as «class GIFf»
else
set imageData to read (POSIX file inPath) as «class BMPf»
end if
set the clipboard to imageData
return "OK"
end run"#;
/// `clipboard_get_image` on macOS: osascript writes the clipboard's PNG to a
/// fresh file under the temp dir, which is read back and removed on every path.
#[cfg(target_os = "macos")]
fn macos_clipboard_image(name: &str) -> Result<Value> {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let path =
std::env::temp_dir().join(format!("ae-clip-image-{}-{nanos}.png", std::process::id()));
let outcome = macos_clipboard_image_to(name, &path);
let _ = std::fs::remove_file(&path);
outcome
}
#[cfg(target_os = "macos")]
fn macos_clipboard_image_to(name: &str, path: &std::path::Path) -> Result<Value> {
let mut cmd = crate::safety::child_command("osascript");
cmd.arg("-e").arg(MACOS_IMAGE_GET_SCRIPT).arg(path);
let output = bounded_cmd_output(name, "osascript", cmd, TOOL_DEADLINE_SECS)?;
if !output.status.success() {
return Err(crate::safety::tool_failed(
name,
"osascript",
output.status.code(),
&gui_stderr_tail(&output),
));
}
match String::from_utf8_lossy(&output.stdout).trim() {
"NONE" => Err(no_clipboard_image(name)),
"OK" => {
let size = std::fs::metadata(path)
.map_err(|e| crate::safety::fs_error(name, path, &e))?
.len();
if size > CLIPBOARD_IMAGE_MAX as u64 {
return Err(crate::safety::budget_exceeded(
name,
format!("the clipboard image is over the {CLIPBOARD_IMAGE_MAX}-byte limit"),
"copy a smaller image; this shell returns no truncated image",
));
}
let bytes = std::fs::read(path).map_err(|e| crate::safety::fs_error(name, path, &e))?;
clipboard_image_answer(name, "image/png", &bytes)
}
other => Err(crate::safety::tool_failed(
name,
"osascript",
Some(0),
&format!("it answered {other:?}, not a clipboard image"),
)),
}
}
/// `clipboard_set_image` on macOS. WEBP has no AppleScript class on macOS, so
/// it is refused before the clipboard is touched.
#[cfg(target_os = "macos")]
fn macos_clipboard_set_image(name: &str, mime: &str, path: &std::path::Path) -> Result<()> {
let kind = match mime {
"image/png" => "png",
"image/jpeg" => "jpeg",
"image/gif" => "gif",
"image/bmp" => "bmp",
_ => {
return Err(crate::safety::unimplemented(
name,
"the macOS clipboard has no WEBP class, so this image was NOT COPIED",
"convert the image to PNG, JPEG, GIF or BMP",
))
}
};
// osascript takes the path as an argument; an absolute one never starts
// with "-", so it cannot be read as an option.
let absolute =
std::fs::canonicalize(path).map_err(|e| crate::safety::fs_error(name, path, &e))?;
let mut cmd = crate::safety::child_command("osascript");
cmd.arg("-e")
.arg(MACOS_IMAGE_SET_SCRIPT)
.arg(&absolute)
.arg(kind);
let output = bounded_cmd_output(name, "osascript", cmd, TOOL_DEADLINE_SECS)?;
if output.status.success() {
return Ok(());
}
Err(crate::safety::tool_failed(
name,
"osascript",
output.status.code(),
&gui_stderr_tail(&output),
))
}
fn bi_clipboard_get_image(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "clipboard_get_image";
no_args_or_input(NAME, &args, &input)?;
crate::safety::clipboard_read_gate(NAME)?;
#[cfg(target_os = "windows")]
{
windows_clipboard_image(NAME)
}
#[cfg(target_os = "macos")]
{
macos_clipboard_image(NAME)
}
#[cfg(target_os = "linux")]
{
let offers = linux_clipboard_offers(NAME)?;
let Some(mime) = CLIPBOARD_IMAGE_TYPES
.iter()
.find(|t| offers.iter().any(|o| o.eq_ignore_ascii_case(t)))
else {
return Err(no_clipboard_image(NAME));
};
let (tool, read_args): (&str, Vec<&str>) = if display_env_set("WAYLAND_DISPLAY") {
("wl-paste", vec!["--no-newline", "--type", mime])
} else {
("xclip", vec!["-selection", "clipboard", "-o", "-t", mime])
};
let out = linux_clipboard_tool(NAME, tool, &read_args)?.ok_or_else(|| {
crate::safety::tool_missing(NAME, tool, "install it to read clipboard images")
})?;
if !out.status.success() {
return Err(crate::safety::tool_failed(
NAME,
tool,
out.status.code(),
&gui_stderr_tail(&out),
));
}
clipboard_image_answer(NAME, mime, &out.stdout)
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
{
Err(crate::safety::unimplemented(
NAME,
"reading an image from the clipboard is implemented on Windows, macOS and Linux only; NOTHING WAS READ",
"use a platform clipboard tool",
))
}
}
/// Copy an image file to the clipboard. The argument, the file and its type
/// are checked before the display, so every refusal but E_NO_UI is the same
/// on every OS.
fn bi_clipboard_set_image(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "clipboard_set_image";
refuse_piped_input(NAME, &input)?;
let path = gui_text(
NAME,
"image path",
gui_required(NAME, &args, 0, "an image path")?,
GUI_FIELD_MAX_BYTES,
false,
)?;
if args.len() > 1 {
return Err(crate::safety::bad_arg(
NAME,
"one argument: the image path",
&format!("{} arguments", args.len()),
));
}
if !std::path::Path::new(&path).exists() {
return Err(crate::safety::not_found(NAME, "image file", &path));
}
let validated = validate_read_path(&path)?;
let size = fs::metadata(&validated)
.map_err(|e| crate::safety::fs_error(NAME, &path, &e))?
.len();
if size > CLIPBOARD_IMAGE_MAX as u64 {
return Err(crate::safety::budget_exceeded(
NAME,
format!("{path} is {size} bytes, over the {CLIPBOARD_IMAGE_MAX}-byte limit"),
"copy a smaller image",
));
}
let bytes = read_bytes_capped(NAME, &validated)?;
let Some(mime) = image_mime_of(&bytes) else {
return Err(crate::safety::bad_arg(
NAME,
"a PNG, JPEG, GIF, BMP or WEBP image",
&format!("{path}, which is none of those"),
));
};
#[cfg(target_os = "linux")]
{
linux_clipboard_set_image(NAME, mime, bytes)?;
Ok(Value::Bool(true))
}
#[cfg(target_os = "windows")]
{
let _ = mime;
windows_clipboard_set_image(NAME, bytes)?;
Ok(Value::Bool(true))
}
#[cfg(target_os = "macos")]
{
macos_clipboard_set_image(NAME, mime, &validated)?;
Ok(Value::Bool(true))
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
{
let _ = (mime, bytes, validated);
Err(crate::safety::unimplemented(
NAME,
"writing an image to the clipboard is implemented on Windows, macOS and Linux only; NOTHING WAS COPIED",
"use a platform clipboard tool",
))
}
}
/// Hand `input` to a clipboard helper on its stdin and wait for it, with a
/// deadline. `child` must have been spawned with a piped stdin. Its stdout and
/// stderr are left however the caller set them and are never read here: a
/// clipboard holder forked by xclip keeps the inherited handles open, so
/// waiting on them would wait for the holder as well.
///
/// The write runs on its own thread and the pipe is closed when it finishes,
/// so a helper that stops reading cannot block the caller. Past `deadline_secs`
/// the child is killed and reaped, and the call is a coded budget error. A
/// BrokenPipe means the helper exited before reading everything. That is not an
/// error here: the exit status decides, and the caller judges it as before.
/// Any other write failure is a coded tool error.
#[cfg(any(target_os = "macos", target_os = "linux", test))]
fn feed_stdin_and_wait(
builtin: &str,
tool: &str,
mut child: std::process::Child,
input: Vec<u8>,
deadline_secs: u64,
) -> Result<std::process::ExitStatus> {
use std::io::Write;
use std::sync::mpsc;
use std::time::{Duration, Instant};
let Some(mut stdin) = child.stdin.take() else {
let _ = child.kill();
let _ = child.wait();
return Err(crate::safety::tool_failed(
builtin,
tool,
None,
"its stdin was not piped",
));
};
let (tx, rx) = mpsc::channel::<std::io::Result<()>>();
std::thread::spawn(move || {
let written = stdin.write_all(&input);
// Dropping the pipe here is what lets the helper see end of input.
drop(stdin);
let _ = tx.send(written);
});
let deadline = Instant::now() + Duration::from_secs(deadline_secs);
let mut write: Option<std::io::Result<()>> = None;
let status = loop {
if write.is_none() {
write = rx.try_recv().ok();
}
if let Some(Err(e)) = &write {
if e.kind() != std::io::ErrorKind::BrokenPipe {
let message = format!("writing its input failed: {e}");
let _ = child.kill();
let _ = child.wait();
return Err(crate::safety::tool_failed(builtin, tool, None, &message));
}
}
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(20));
}
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
return Err(crate::safety::budget_exceeded(
builtin,
format!("`{tool}` did not finish within {deadline_secs} s and was stopped"),
"the clipboard may be unchanged; check it, then retry",
));
}
Err(e) => {
let _ = child.kill();
let _ = child.wait();
return Err(crate::safety::tool_failed(
builtin,
tool,
None,
&e.to_string(),
));
}
}
};
if write.is_none() {
// The helper has exited; the write has at most a moment left to report.
write = rx.recv_timeout(Duration::from_millis(200)).ok();
}
match write {
Some(Err(e)) if e.kind() != std::io::ErrorKind::BrokenPipe => {
Err(crate::safety::tool_failed(
builtin,
tool,
None,
&format!("writing its input failed: {e}"),
))
}
_ => Ok(status),
}
}
/// Hand `bytes` to xclip or wl-copy as `mime`.
///
/// Not through bounded_cmd_output or run_tree, on purpose. Both tools fork a
/// holder that owns the selection after the command returns, and run_tree puts
/// its child in a new process group and kills that group when a pipe outlives
/// the child: it would kill the holder and empty the clipboard again. So the
/// tool keeps this shell's process group, gets null stdout and stderr (the
/// holder keeps no pipe of ours), and only the direct child is waited on, with
/// a deadline, through feed_stdin_and_wait.
#[cfg(target_os = "linux")]
fn linux_clipboard_set_image(name: &str, mime: &str, bytes: Vec<u8>) -> Result<()> {
use std::process::Stdio;
if !display_env_set("DISPLAY") && !display_env_set("WAYLAND_DISPLAY") {
return Err(crate::safety::no_ui(name));
}
let (tool, tool_args): (&str, Vec<&str>) = if display_env_set("WAYLAND_DISPLAY") {
("wl-copy", vec!["--type", mime])
} else {
("xclip", vec!["-selection", "clipboard", "-t", mime, "-i"])
};
let mut clip = crate::safety::child_command(tool);
crate::safety::strip_ironshell_env(&mut clip);
let child = clip
.args(&tool_args)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| crate::safety::spawn_error(name, tool, &e))?;
let status = feed_stdin_and_wait(name, tool, child, bytes, TOOL_DEADLINE_SECS)?;
if status.success() {
return Ok(());
}
Err(crate::safety::tool_failed(
name,
tool,
status.code(),
"it did not take the image",
))
}
fn bi_clipboard_history(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "clipboard_history";
no_args_or_input(NAME, &args, &input)?;
crate::safety::clipboard_read_gate(NAME)?;
#[cfg(target_os = "windows")]
{
let mut cmd = crate::safety::child_command("powershell");
cmd.args([
"-NoProfile",
"-NonInteractive",
"-Command",
"Get-ClipboardHistory | Select-Object -First 10 | ForEach-Object { $_.Text }",
]);
let output = bounded_cmd_output(NAME, "powershell", cmd, TOOL_DEADLINE_SECS)?;
if !output.status.success() {
// Previously answered a String explaining the requirement, which
// read as a list of one entry. The requirement is an error.
return Err(crate::safety::tool_failed(
NAME,
"Get-ClipboardHistory",
output.status.code(),
&gui_stderr_tail(&output),
));
}
let text = String::from_utf8_lossy(&output.stdout).into_owned();
Ok(Value::Array(
text.lines().map(|l| Value::Str(l.to_string())).collect(),
))
}
#[cfg(not(target_os = "windows"))]
{
Err(crate::safety::unimplemented(
NAME,
"clipboard history is implemented on Windows 10 and later only; NOTHING WAS READ",
"clipboard() reads the current clipboard text",
))
}
}
/// `input_read_char(timeout_ms?)`: one keypress from the terminal, answered as
/// a String. Needs stdin and stderr to be terminals; otherwise it refuses with
/// E_NO_UI without reading anything, so piped stdin is left for the next read.
/// Ctrl+C is refused (E_POLICY_DENY), not answered: raw mode delivers it as a
/// key, and answering it as a character would hide that the user cancelled.
fn bi_input_read_char(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const BUILTIN: &str = "input_read_char";
const MAX_MS: i64 = 3_600_000;
if input.is_some() {
return Err(crate::safety::bad_arg(
BUILTIN,
"no pipeline input",
"a piped value",
));
}
let timeout = match args.as_slice() {
[] => None,
[Value::Int(ms)] if (1..=MAX_MS).contains(ms) => {
Some(std::time::Duration::from_millis(*ms as u64))
}
[other] => {
return Err(crate::safety::bad_arg(
BUILTIN,
"an Int timeout_ms from 1 through 3600000",
&format!("{other:?}"),
))
}
more => {
return Err(crate::safety::bad_arg(
BUILTIN,
"input_read_char(timeout_ms?: Int)",
&format!("{} arguments", more.len()),
))
}
};
use std::io::IsTerminal;
if !(std::io::stdin().is_terminal() && std::io::stderr().is_terminal()) {
return Err(crate::safety::no_ui(BUILTIN));
}
read_key_native(BUILTIN, timeout)
}
#[cfg(feature = "native")]
fn read_key_native(builtin: &str, timeout: Option<std::time::Duration>) -> Result<Value> {
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use crossterm::terminal;
let io = |e: std::io::Error| crate::safety::fs_error(builtin, "the terminal", &e);
// Same discipline as line_editor::terminal::read_line: raw mode is
// restored on every path, including a failed read, before the result
// is returned.
terminal::enable_raw_mode().map_err(io)?;
let outcome = (|| -> Result<Option<String>> {
let deadline = timeout.map(|t| std::time::Instant::now() + t);
loop {
if let Some(deadline) = deadline {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() || !event::poll(remaining).map_err(io)? {
return Ok(None);
}
}
let Event::Key(key) = event::read().map_err(io)? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
if let KeyCode::Char('c' | 'C') = key.code {
if key.modifiers.contains(KeyModifiers::CONTROL) {
return Err(crate::safety::policy_deny(
builtin,
format!(
"{builtin}: Ctrl+C was pressed; the key was cancelled, not answered"
),
"the user cancelled this read; ask again only if they want to answer",
));
}
}
// Keys with no name here (modifier-only presses, media keys) are
// skipped, so the answer is always a key an agent can match.
if let Some(name) = key_name(key.code) {
return Ok(Some(name));
}
}
})();
let restored = terminal::disable_raw_mode();
let answer = outcome?;
restored.map_err(io)?;
Ok(answer.map_or(Value::Null, Value::Str))
}
#[cfg(feature = "native")]
fn key_name(code: crossterm::event::KeyCode) -> Option<String> {
use crossterm::event::KeyCode;
Some(match code {
KeyCode::Char(c) => c.to_string(),
KeyCode::Enter => "Enter".to_string(),
KeyCode::Esc => "Esc".to_string(),
KeyCode::Tab => "Tab".to_string(),
KeyCode::BackTab => "BackTab".to_string(),
KeyCode::Backspace => "Backspace".to_string(),
KeyCode::Delete => "Delete".to_string(),
KeyCode::Insert => "Insert".to_string(),
KeyCode::Home => "Home".to_string(),
KeyCode::End => "End".to_string(),
KeyCode::PageUp => "PageUp".to_string(),
KeyCode::PageDown => "PageDown".to_string(),
KeyCode::Up => "Up".to_string(),
KeyCode::Down => "Down".to_string(),
KeyCode::Left => "Left".to_string(),
KeyCode::Right => "Right".to_string(),
KeyCode::F(n) => format!("F{n}"),
_ => return None,
})
}
#[cfg(not(feature = "native"))]
fn read_key_native(builtin: &str, timeout: Option<std::time::Duration>) -> Result<Value> {
let _ = timeout;
Err(crate::safety::unimplemented(
builtin,
"reading a keypress needs the `native` feature (crossterm); NOTHING WAS READ",
"build with default features, or use `ish tui`",
))
}
/// `input_spinner(label, duration_ms)`: animates `| / - \` on stderr for
/// `duration_ms`, then clears its line and answers `{label, duration_ms}`.
/// When stderr is not a terminal nothing is drawn (no CR or escape bytes reach
/// a log), but the call still waits the full duration. Control characters in
/// the label are drawn as `?`; the answer carries the label unchanged.
fn bi_input_spinner(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const BUILTIN: &str = "input_spinner";
const MAX_MS: i64 = 600_000;
const MAX_LABEL_BYTES: usize = 1024;
const FRAME_MS: u64 = 100;
const FRAMES: [char; 4] = ['|', '/', '-', '\\'];
if input.is_some() {
return Err(crate::safety::bad_arg(
BUILTIN,
"no pipeline input",
"a piped value",
));
}
let [label, duration] = args.as_slice() else {
return Err(crate::safety::bad_arg(
BUILTIN,
"input_spinner(label: String, duration_ms: Int)",
&format!("{} arguments", args.len()),
));
};
let Value::Str(label) = label else {
return Err(crate::safety::bad_arg(
BUILTIN,
"a String label",
&format!("{label:?}"),
));
};
if label.len() > MAX_LABEL_BYTES {
return Err(crate::safety::bad_arg(
BUILTIN,
"a label of at most 1024 bytes",
&format!("{} bytes", label.len()),
));
}
let ms = match duration {
Value::Int(ms) if (1..=MAX_MS).contains(ms) => *ms as u64,
other => {
return Err(crate::safety::bad_arg(
BUILTIN,
"an Int duration_ms from 1 through 600000",
&format!("{other:?}"),
))
}
};
use std::io::IsTerminal;
let draw = std::io::stderr().is_terminal();
let shown: String = label
.chars()
.map(|c| if c.is_control() { '?' } else { c })
.collect();
let total = std::time::Duration::from_millis(ms);
let start = std::time::Instant::now();
let mut frame = 0usize;
loop {
let elapsed = start.elapsed();
if elapsed >= total {
break;
}
if draw {
eprint!("\r{} {}", FRAMES[frame % FRAMES.len()], shown);
}
frame += 1;
std::thread::sleep((total - elapsed).min(std::time::Duration::from_millis(FRAME_MS)));
}
if draw {
eprint!("\r{}\r", " ".repeat(shown.chars().count() + 2));
}
let mut rec = std::collections::BTreeMap::new();
rec.insert("label".to_string(), Value::Str(label.clone()));
rec.insert("duration_ms".to_string(), Value::Int(ms as i64));
Ok(Value::Record(rec))
}
/// `input_progress(current, total, label?)`: draws one progress line on stderr
/// and answers `{current, total, percent}`. Nothing is kept between calls.
fn bi_input_progress(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const BUILTIN: &str = "input_progress";
const WIDTH: usize = 30;
if input.is_some() {
return Err(crate::safety::bad_arg(
BUILTIN,
"no pipeline input",
"a piped value",
));
}
if !(2..=3).contains(&args.len()) {
return Err(crate::safety::bad_arg(
BUILTIN,
"input_progress(current: Number, total: Number, label?: String)",
&format!("{} arguments", args.len()),
));
}
let number = |v: &Value, what: &str| -> Result<f64> {
let n = match v {
Value::Int(i) => *i as f64,
Value::Float(f) => *f,
other => {
return Err(crate::safety::bad_arg(
BUILTIN,
&format!("a Number {what}"),
&format!("{other:?}"),
))
}
};
if !n.is_finite() {
return Err(crate::safety::bad_arg(
BUILTIN,
&format!("a finite Number {what}"),
&format!("{n}"),
));
}
Ok(n)
};
let current_value = &args[0];
let total_value = &args[1];
let current = number(current_value, "current")?;
let total = number(total_value, "total")?;
if total <= 0.0 {
return Err(crate::safety::bad_arg(
BUILTIN,
"a total greater than 0",
&format!("{total}"),
));
}
if current < 0.0 || current > total {
return Err(crate::safety::bad_arg(
BUILTIN,
"0 <= current <= total",
&format!("current {current} of total {total}"),
));
}
let label = match args.get(2) {
None => None,
Some(Value::Str(s)) => Some(s.as_str()),
Some(other) => {
return Err(crate::safety::bad_arg(
BUILTIN,
"a String label",
&format!("{other:?}"),
))
}
};
let fraction = (current / total).clamp(0.0, 1.0);
let percent = (fraction * 100.0).floor() as i64;
let filled = ((fraction * WIDTH as f64).floor() as usize).min(WIDTH);
let bar = format!("{}{}", "#".repeat(filled), "-".repeat(WIDTH - filled));
let prefix = match label {
Some(text) => format!("{text} "),
None => String::new(),
};
let finished = current == total;
eprint!("\r{prefix}[{bar}] {percent}%");
if finished {
eprintln!();
}
let mut record = std::collections::BTreeMap::new();
record.insert("current".to_string(), current_value.clone());
record.insert("total".to_string(), total_value.clone());
record.insert("percent".to_string(), Value::Int(percent));
Ok(Value::Record(record))
}
/// Most fields one `input_form` asks about.
const INPUT_FORM_MAX_FIELDS: usize = 50;
/// Longest label, default or choice text a line prompt accepts.
const INPUT_TEXT_MAX_BYTES: usize = 1024;
/// Invalid answers a line prompt tolerates before it refuses.
const INPUT_MAX_ATTEMPTS: usize = 3;
/// Most rows one `input_table` lists.
const INPUT_TABLE_MAX_ROWS: usize = 1000;
/// Columns one `input_table` shows; the chosen row still carries every key.
const INPUT_TABLE_MAX_COLUMNS: usize = 16;
/// Characters of one table cell shown before it is cut with `...`.
const INPUT_TABLE_CELL_CHARS: usize = 40;
/// Most choices one `input_autocomplete` accepts.
const INPUT_AUTOCOMPLETE_MAX_CHOICES: usize = 10000;
/// Matches one `input_autocomplete` lists when a prefix is ambiguous.
const INPUT_AUTOCOMPLETE_LIST: usize = 20;
/// Answers one `input_autocomplete` accepts before it refuses.
const INPUT_AUTOCOMPLETE_MAX_ATTEMPTS: usize = 5;
/// A form field name: `^[A-Za-z_][A-Za-z0-9_]{0,63}$`.
fn is_form_field_name(name: &str) -> bool {
let bytes = name.as_bytes();
matches!(bytes.first(), Some(b) if b.is_ascii_alphabetic() || *b == b'_')
&& bytes.len() <= 64
&& bytes
.iter()
.all(|b| b.is_ascii_alphanumeric() || *b == b'_')
}
/// One table cell or column title: control characters become spaces, and text
/// over the cell width is cut with `...`.
fn input_table_cell(text: &str) -> String {
let flat: String = text
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
if flat.chars().count() <= INPUT_TABLE_CELL_CHARS {
return flat;
}
let mut cut: String = flat.chars().take(INPUT_TABLE_CELL_CHARS - 3).collect();
cut.push_str("...");
cut
}
/// A form of named fields, asked one line each. Every field is validated
/// before the first prompt, so a malformed form asks nothing.
fn bi_input_form(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "input_form";
if input.is_some() {
return Err(crate::safety::bad_arg(
NAME,
"a direct call; the answers come from the terminal, not piped input",
"a piped value",
));
}
let [fields] = args.as_slice() else {
return Err(crate::safety::bad_arg(
NAME,
"fields: Array of field Records",
&format!("{} argument(s)", args.len()),
));
};
let Value::Array(specs) = fields else {
return Err(crate::safety::bad_arg(
NAME,
"fields: Array of field Records",
fields.type_name(),
));
};
if specs.is_empty() || specs.len() > INPUT_FORM_MAX_FIELDS {
return Err(crate::safety::bad_arg(
NAME,
&format!("1..={INPUT_FORM_MAX_FIELDS} fields"),
&format!("{} field(s)", specs.len()),
));
}
// (name, label, default, required), validated in full before any prompt.
let mut checked: Vec<(String, String, Option<String>, bool)> = Vec::with_capacity(specs.len());
let mut seen = std::collections::BTreeSet::new();
for (i, spec) in specs.iter().enumerate() {
let position = i + 1;
let Value::Record(rec) = spec else {
return Err(crate::safety::bad_arg(
NAME,
&format!("field {position}: a Record"),
spec.type_name(),
));
};
if let Some(key) = rec
.keys()
.find(|k| !matches!(k.as_str(), "name" | "label" | "default" | "required"))
{
return Err(crate::safety::bad_arg(
NAME,
&format!("field {position}: keys name, label, default, required"),
&format!("unknown key {key:?}"),
));
}
let name = match rec.get("name") {
Some(Value::Str(s)) if is_form_field_name(s) => s.clone(),
Some(Value::Str(s)) => {
return Err(crate::safety::bad_arg(
NAME,
&format!("field {position}: name matching ^[A-Za-z_][A-Za-z0-9_]{{0,63}}$"),
&format!("{s:?}"),
))
}
Some(other) => {
return Err(crate::safety::bad_arg(
NAME,
&format!("field {position}: name as a String"),
other.type_name(),
))
}
None => {
return Err(crate::safety::bad_arg(
NAME,
&format!("field {position}: a name"),
"no name",
))
}
};
if !seen.insert(name.clone()) {
return Err(crate::safety::bad_arg(
NAME,
"field names that are unique",
&format!("duplicate {name:?}"),
));
}
let text = |key: &str| -> Result<Option<String>> {
match rec.get(key) {
None => Ok(None),
Some(Value::Str(s)) if s.len() <= INPUT_TEXT_MAX_BYTES => Ok(Some(s.clone())),
Some(Value::Str(s)) => Err(crate::safety::bad_arg(
NAME,
&format!("field {position}: {key} of at most {INPUT_TEXT_MAX_BYTES} bytes"),
&format!("{} bytes", s.len()),
)),
Some(other) => Err(crate::safety::bad_arg(
NAME,
&format!("field {position}: {key} as a String"),
other.type_name(),
)),
}
};
let label = text("label")?
.filter(|l| !l.is_empty())
.unwrap_or_else(|| name.clone());
let default = text("default")?;
let required = match rec.get("required") {
None => false,
Some(Value::Bool(b)) => *b,
Some(other) => {
return Err(crate::safety::bad_arg(
NAME,
&format!("field {position}: required as a Bool"),
other.type_name(),
))
}
};
checked.push((name, label, default, required));
}
let mut answers = BTreeMap::new();
for (name, label, default, required) in &checked {
let prompt = match default {
Some(d) => format!("{label} ({d}): "),
None => format!("{label}: "),
};
let mut empty_answers = 0usize;
let answer = loop {
let raw = read_answer(NAME, &prompt)?;
if !raw.is_empty() {
break raw;
}
if let Some(d) = default {
break d.clone();
}
if !required {
break String::new();
}
empty_answers += 1;
if empty_answers == INPUT_MAX_ATTEMPTS {
return Err(crate::safety::bad_arg(
NAME,
&format!("a non-empty answer for required field `{name}`"),
&format!("{empty_answers} empty answers"),
));
}
eprintln!("{NAME}: `{name}` is required");
};
answers.insert(name.clone(), Value::Str(answer));
}
Ok(Value::Record(answers))
}
/// Lists rows as a numbered table on stderr, then answers the row the user
/// picks, as it was given.
fn bi_input_table(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "input_table";
if input.is_some() {
return Err(crate::safety::bad_arg(
NAME,
"a direct call; the choice comes from the terminal, not piped input",
"a piped value",
));
}
if args.is_empty() || args.len() > 2 {
return Err(crate::safety::bad_arg(
NAME,
"rows: Array of Records, prompt?: String",
&format!("{} argument(s)", args.len()),
));
}
let Value::Array(rows) = &args[0] else {
return Err(crate::safety::bad_arg(
NAME,
"rows: Array of Records",
args[0].type_name(),
));
};
let prompt = match args.get(1) {
None => None,
Some(Value::Str(p)) => Some(p.clone()),
Some(other) => {
return Err(crate::safety::bad_arg(
NAME,
"prompt: String",
other.type_name(),
))
}
};
if rows.is_empty() || rows.len() > INPUT_TABLE_MAX_ROWS {
return Err(crate::safety::bad_arg(
NAME,
&format!("1..={INPUT_TABLE_MAX_ROWS} rows"),
&format!("{} row(s)", rows.len()),
));
}
let mut columns = std::collections::BTreeSet::new();
for (i, row) in rows.iter().enumerate() {
let Value::Record(rec) = row else {
return Err(crate::safety::bad_arg(
NAME,
&format!("row {}: a Record", i + 1),
row.type_name(),
));
};
columns.extend(rec.keys().map(String::as_str));
}
let shown: Vec<&str> = columns
.iter()
.copied()
.take(INPUT_TABLE_MAX_COLUMNS)
.collect();
let hidden = columns.len() - shown.len();
let mut table = String::from(" #");
for column in &shown {
table.push_str(" | ");
table.push_str(&input_table_cell(column));
}
if hidden > 0 {
table.push_str(&format!(" | (+{hidden} more columns)"));
}
table.push('\n');
for (i, row) in rows.iter().enumerate() {
table.push_str(&format!("{:>4}", i + 1));
if let Value::Record(rec) = row {
for column in &shown {
let cell = rec
.get(*column)
.map(|v| v.to_display_string())
.unwrap_or_default();
table.push_str(" | ");
table.push_str(&input_table_cell(&cell));
}
}
table.push('\n');
}
eprint!("{table}");
let question = prompt.unwrap_or_else(|| format!("Select a row (1-{}): ", rows.len()));
for _ in 0..INPUT_MAX_ATTEMPTS {
let answer = read_answer(NAME, &question)?;
match answer.trim().parse::<usize>() {
Ok(n) if (1..=rows.len()).contains(&n) => return Ok(rows[n - 1].clone()),
_ => eprintln!(
"{NAME}: {answer:?} is not a row number in 1..={}",
rows.len()
),
}
}
Err(crate::safety::bad_arg(
NAME,
&format!("a row number in 1..={}", rows.len()),
&format!("no valid answer in {INPUT_MAX_ATTEMPTS} attempts"),
))
}
/// `input_date(prompt?)`: a `YYYY-MM-DD` date the calendar accepts, answered as
/// its text. Anything else is asked again; after the last attempt the call
/// refuses with E_BAD_ARG.
fn bi_input_date(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "input_date";
direct_args(
NAME,
&args,
input.is_some(),
0,
1,
"input_date(prompt?: String)",
)?;
let prompt = optional_prompt(NAME, &args, "Enter date (YYYY-MM-DD): ")?;
ask_until(NAME, &prompt, "a date as YYYY-MM-DD", |answer| {
parse_date(answer.trim())
})
.map(Value::Str)
}
// ============================================================================
// Missing Crypto implementations
// ============================================================================
fn bi_crypto_cert_verify(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let cert_path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"crypto_cert_verify",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
crate::safety::reject_option_like("crypto_cert_verify", std::slice::from_ref(&cert_path))?;
// A certificate file that did not exist answered false, "invalid".
if !std::path::Path::new(&cert_path).is_file() {
return Err(crate::safety::not_found(
"crypto_cert_verify",
"certificate file",
&cert_path,
));
}
// The same size limit as crypto_cert_info, checked before the file is read.
let metadata = std::fs::metadata(&cert_path)
.map_err(|e| crate::safety::fs_error("crypto_cert_verify", &cert_path, &e))?;
if metadata.len() > 64 * 1024 * 1024 {
return Err(crate::safety::budget_exceeded(
"crypto_cert_verify",
"certificate input exceeds 64 MiB".into(),
"verify a smaller file",
));
}
// A file that exists but cannot be read is a coded error, never false.
let bytes = std::fs::read(&cert_path)
.map_err(|e| crate::safety::fs_error("crypto_cert_verify", &cert_path, &e))?;
// IronSocketLayer's X.509 chain validation against the system trust store.
// Only a chain that does not validate is false; a missing trust store,
// an unsupported key, or exceeded bounds are coded errors. See
// src/certificates.rs.
Ok(Value::Bool(crate::certificates::verify_file(
"crypto_cert_verify",
&bytes,
)?))
}
fn bi_crypto_generate_key(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
bi_crypto_key_generate(args, input)
}
/// Signature verification returns a Bool only after structural input validation.
fn bi_crypto_verify_signature(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
digital_signatures::verify(args, input)
}
fn bi_crypto_jwt_decode(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let token = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"crypto_jwt_decode",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
// The payload, decoded without verifying the signature. This shelled
// out to `base64` (Unix) or PowerShell (Windows) and answered null for a
// malformed token, a bad segment or a payload that was not JSON.
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
let bad = |why: &str| {
crate::safety::bad_arg("crypto_jwt_decode", "a JWT (header.payload.signature)", why)
};
let parts: Vec<&str> = token.trim().split('.').collect();
if parts.len() != 3 {
return Err(bad(&format!("{} dot-separated segment(s)", parts.len())));
}
let bytes = URL_SAFE_NO_PAD
.decode(parts[1].trim_end_matches('='))
.map_err(|e| bad(&format!("a payload that is not base64url: {e}")))?;
let json: serde_json::Value = serde_json::from_slice(&bytes)
.map_err(|e| bad(&format!("a payload that is not JSON: {e}")))?;
Ok(json_to_value(json))
}
// ============================================================================
// Missing Database implementations
// ============================================================================
fn bi_db_sqlite_open(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// Just verify the database can be opened
bi_db_sqlite_create(args, None)
}
/// Render one `Value` as a SQL literal.
///
/// Strings go through [`crate::safety::sql_literal`] rather than a local
/// `replace('\'', "''")`. The two agree on doubling quotes, but the helper also
/// refuses an interior NUL — the sqlite3 CLI reads its SQL as a C string, so a
/// NUL silently truncates the statement, and storing a shortened value is its own
/// bug. Two call sites had hand-rolled the escaping and inherited neither that
/// check nor any later fix to it.
fn sql_value(builtin: &str, v: &Value) -> Result<String> {
Ok(match v {
Value::Str(s) => crate::safety::sql_literal(builtin, s)?,
Value::Int(n) => n.to_string(),
Value::Float(f) if f.is_finite() => f.to_string(),
Value::Bool(b) => if *b { "1" } else { "0" }.to_string(),
Value::Null => "NULL".to_string(),
other => {
return Err(crate::safety::bad_arg(
builtin,
"String, Int, finite Float, Bool or Null SQL cell",
other.type_name(),
))
}
})
}
fn bi_db_sqlite_insert(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let db_path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_insert",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let table = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_insert",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let values = match args.get(2) {
Some(Value::Record(r)) => r.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_insert",
"Record",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
if values.is_empty() {
return Err(crate::safety::arg_err(
"db_sqlite_insert: values must be a nonempty Record",
));
}
// Column names are identifiers, and they come from a caller-supplied record,
// so they are exactly as controlled as the values are. Escaping the values
// and not the keys made the gap easier to miss, not smaller: a key of
// `id) VALUES (1); DROP TABLE victim; --` closed the statement and the
// sqlite3 CLI ran what followed.
let columns: Vec<String> = values
.keys()
.map(|k| crate::safety::sql_identifier("db_sqlite_insert", k))
.collect::<Result<_>>()?;
let vals: Vec<String> = values
.values()
.map(|v| sql_value("db_sqlite_insert", v))
.collect::<Result<_>>()?;
let sql = format!(
"INSERT INTO {} ({}) VALUES ({})",
crate::safety::sql_identifier("db_sqlite_insert", &table)?,
columns.join(", "),
vals.join(", ")
);
sqlite_exec_contained(vec![Value::Str(db_path), Value::Str(sql)])
}
fn bi_db_sqlite_update(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let db_path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_update",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let table = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_update",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let values = match args.get(2) {
Some(Value::Record(r)) => r.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_update",
"Record",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let where_clause = match args.get(3) {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_update",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
if values.is_empty() || where_clause.trim().is_empty() {
return Err(crate::safety::arg_err(
"db_sqlite_update: values and WHERE clause must be nonempty",
));
}
crate::safety::reject_sql_batch("db_sqlite_update", &where_clause)?;
// Same as `db_sqlite_insert`: the record's keys are identifiers and are
// caller-controlled. The WHERE clause is SQL by contract -- this builtin
// exists to take one -- so it stays the caller's, matching the note in
// `db_sqlite_delete`.
let sets: Vec<String> = values
.iter()
.map(|(k, v)| {
Ok(format!(
"{} = {}",
crate::safety::sql_identifier("db_sqlite_update", k)?,
sql_value("db_sqlite_update", v)?
))
})
.collect::<Result<_>>()?;
let sql = format!(
"UPDATE {} SET {} WHERE {}",
crate::safety::sql_identifier("db_sqlite_update", &table)?,
sets.join(", "),
where_clause
);
sqlite_exec_contained(vec![Value::Str(db_path), Value::Str(sql)])
}
fn bi_db_sqlite_delete(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let db_path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_delete",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let table = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_delete",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let where_clause = match args.get(2) {
Some(Value::Str(s)) if !s.trim().is_empty() => s.clone(),
None => "1=0".to_string(), // Safety: don't delete all by default
Some(other) => {
return Err(crate::safety::bad_arg(
"db_sqlite_delete",
"where: String",
other.type_name(),
))
}
};
// Refused before the approval prompt: a malformed condition is not worth asking about.
crate::safety::reject_sql_batch("db_sqlite_delete", &where_clause)?;
crate::safety::guard(crate::safety::GuardCtx {
builtin: "db_sqlite_delete",
effect: crate::safety::Effect::Destructive,
what: "db_delete",
targets: vec![format!("{}:{}", db_path, table)],
blast_radius: serde_json::json!({
"db": db_path.clone(), "table": table.clone(), "where": where_clause.clone()
}),
reversible: false,
fs_paths: false,
})?;
// The table is an identifier and is validated; the WHERE clause is SQL by
// contract -- this builtin exists to take one -- so it is the caller's.
let sql = format!(
"DELETE FROM {} WHERE {}",
crate::safety::sql_identifier("db_sqlite_delete", &table)?,
where_clause
);
sqlite_exec_contained(vec![Value::Str(db_path), Value::Str(sql)])
}
fn bi_db_sqlite_count(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let db_path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_count",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let table = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_count",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let where_clause = args.get(2).and_then(|v| match v {
Value::Str(s) => Some(s.clone()),
_ => None,
});
let sql = if let Some(w) = where_clause {
format!(
"SELECT COUNT(*) as count FROM {} WHERE {}",
crate::safety::sql_identifier("db_sqlite_count", &table)?,
w
)
} else {
format!(
"SELECT COUNT(*) as count FROM {}",
crate::safety::sql_identifier("db_sqlite_count", &table)?
)
};
// This matched only a String count. db_sqlite_query returns it as an Int,
// so every table counted 0 -- a 3-row table included.
let rows = bi_db_sqlite_query(vec![Value::Str(db_path), Value::Str(sql)], None)?;
let count = match &rows {
Value::Array(arr) => match arr.first() {
Some(Value::Record(rec)) => match rec.get("count") {
Some(Value::Int(n)) => Some(*n),
Some(Value::Str(s)) => s.trim().parse().ok(),
_ => None,
},
_ => None,
},
_ => None,
};
count.map(Value::Int).ok_or_else(|| {
crate::safety::tool_failed(
"db_sqlite_count",
"sqlite3 SELECT COUNT(*)",
None,
&format!("no count in the result: {}", rows.to_display_string()),
)
})
}
fn bi_db_sqlite_create_table(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let db_path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_create_table",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let table = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_create_table",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let columns = match args.get(2) {
Some(Value::Record(r)) => r.clone(),
Some(Value::Str(s)) => {
// Allow raw column definition
if s.trim().is_empty() {
return Err(crate::safety::arg_err(
"db_sqlite_create_table: column definition must be nonempty",
));
}
crate::safety::reject_sql_batch("db_sqlite_create_table", s)?;
let sql = format!(
"CREATE TABLE IF NOT EXISTS {} ({})",
crate::safety::sql_identifier("db_sqlite_create_table", &table)?,
s
);
return sqlite_exec_contained(vec![Value::Str(db_path), Value::Str(sql)]);
}
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_create_table",
"Record or String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
if columns.is_empty() {
return Err(crate::safety::arg_err(
"db_sqlite_create_table: columns must be a nonempty Record",
));
}
// Record-form column names and type strings are validated before execution.
// The explicit raw-SQL string form above retains the caller's SQL grammar.
let col_defs: Vec<String> = columns
.iter()
.map(|(name, typ)| {
let name = crate::safety::sql_identifier("db_sqlite_create_table", name)?;
let type_str = match typ {
Value::Str(s) => crate::safety::sql_column_type("db_sqlite_create_table", s)?,
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_create_table",
"column type: String",
other.type_name(),
))
}
};
Ok(format!("{} {}", name, type_str))
})
.collect::<Result<_>>()?;
let sql = format!(
"CREATE TABLE IF NOT EXISTS {} ({})",
crate::safety::sql_identifier("db_sqlite_create_table", &table)?,
col_defs.join(", ")
);
sqlite_exec_contained(vec![Value::Str(db_path), Value::Str(sql)])
}
fn bi_db_sqlite_drop_table(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let db_path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_drop_table",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let table = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_drop_table",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
crate::safety::guard(crate::safety::GuardCtx {
builtin: "db_sqlite_drop_table",
effect: crate::safety::Effect::Destructive,
what: "db_sqlite_drop_table",
targets: vec![format!("{}:{}", db_path, table)],
blast_radius: serde_json::json!({ "db": db_path, "table": table }),
// Reversible only inside a transaction — db_sqlite_exec snapshots the
// file, so a rollback restores it byte-for-byte.
reversible: crate::tx::is_active(),
fs_paths: false,
})?;
// Validated after the guard, so an approval prompt still describes the table
// the caller named rather than a sanitised version of it.
let sql = format!(
"DROP TABLE IF EXISTS {}",
crate::safety::sql_identifier("db_sqlite_drop_table", &table)?
);
sqlite_exec_contained(vec![Value::Str(db_path), Value::Str(sql)])
}
fn bi_db_sqlite_vacuum(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let db_path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_vacuum",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
sqlite_exec_contained(vec![Value::Str(db_path), Value::Str("VACUUM".to_string())])
}
fn bi_db_sqlite_dump(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let db_path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_dump",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
crate::safety::reject_option_like("db_sqlite_dump", std::slice::from_ref(&db_path))?;
if !sqlite_db_exists(&db_path) {
return Err(crate::safety::not_found(
"db_sqlite_dump",
"database",
&db_path,
));
}
sqlite_db_file("db_sqlite_dump", &db_path)?;
let mut cmd = crate::safety::child_command("sqlite3");
cmd.args(["-safe", "-readonly", &db_path, ".dump"]);
let output = bounded_cmd_output("db_sqlite_dump", "sqlite3", cmd, TOOL_DEADLINE_SECS)?;
if output.status.success() {
return Ok(Value::Str(
String::from_utf8_lossy(&output.stdout).to_string(),
));
}
// A failure answered null.
Err(crate::safety::tool_failed(
"db_sqlite_dump",
"sqlite3 .dump",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
))
}
fn bi_db_sqlite_import(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let db_path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_import",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let sql_file = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_import",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let sql = read_text_capped("db_sqlite_import", &sql_file)?;
sqlite_exec_contained(vec![Value::Str(db_path), Value::Str(sql)])
}
fn bi_db_json_to_sqlite(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// A malformed call used to return `Ok(Value::Bool(false))`: no error, exit
// 0, and a value the caller may well read as "the import found nothing".
// Same shape as `round(x, digits)` discarding `digits` and `env(name,
// default)` discarding `default` -- a wrong answer with nothing in it that
// says to look again. The argument order is (db, json, table), which is the
// opposite of what the name reads like, so getting it wrong is the likely
// mistake and it must say so.
let db_path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_json_to_sqlite",
"a database path as the first argument: db_json_to_sqlite(db, json, table)",
other.map_or("nothing", |v| v.type_name()),
))
}
};
let json_path = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_json_to_sqlite",
"a JSON path as the second argument: db_json_to_sqlite(db, json, table)",
other.map_or("nothing", |v| v.type_name()),
))
}
};
let table_name = match args.get(2) {
Some(Value::Str(s)) => s.clone(),
_ => "imported".to_string(),
};
// Read JSON
let content = read_text_capped("db_json_to_sqlite", &json_path)?;
// Everything is checked and every statement built before the first write.
// This answered true after skipping any row that was not an object, wrote a
// nested value as NULL, and failed partway on a row with a key the table
// lacks, leaving the rows before it behind.
let json: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
crate::safety::bad_arg(
"db_json_to_sqlite",
"a JSON file holding an array of objects",
&e.to_string(),
)
})?;
let serde_json::Value::Array(rows) = json else {
return Err(crate::safety::bad_arg(
"db_json_to_sqlite",
"a JSON array of objects",
"another JSON value",
));
};
// An empty array names no columns, so no table can be made: nothing imported.
let Some(first) = rows.first() else {
return Ok(Value::Bool(false));
};
let serde_json::Value::Object(first) = first else {
return Err(crate::safety::bad_arg(
"db_json_to_sqlite",
"a JSON array of objects",
"row 0 is not an object",
));
};
// Column names come from the *file*, so the injection payload does not even
// have to be an argument: a JSON object key of
// `x TEXT); DROP TABLE victim; --` closed the statement and the sqlite3 CLI
// ran what followed. Identifiers are validated for that reason, which also
// means a key that is not a legal column name is refused rather than
// producing broken SQL.
let columns: Vec<String> = first
.keys()
.map(|k| {
Ok(format!(
"{} TEXT",
crate::safety::sql_identifier("db_json_to_sqlite", k)?
))
})
.collect::<Result<_>>()?;
let create_sql = format!(
"CREATE TABLE IF NOT EXISTS {} ({})",
crate::safety::sql_identifier("db_json_to_sqlite", &table_name)?,
columns.join(", ")
);
let mut inserts = Vec::with_capacity(rows.len());
for (index, row) in rows.iter().enumerate() {
let serde_json::Value::Object(obj) = row else {
return Err(crate::safety::bad_arg(
"db_json_to_sqlite",
"a JSON array of objects",
&format!("row {index} is not an object"),
));
};
if let Some(extra) = obj.keys().find(|k| !first.contains_key(*k)) {
return Err(crate::safety::bad_arg(
"db_json_to_sqlite",
"rows whose keys all appear in the first row, which names the columns",
&format!("row {index} has the key {extra:?}"),
));
}
let cols: Vec<String> = obj
.keys()
.map(|k| crate::safety::sql_identifier("db_json_to_sqlite", k))
.collect::<Result<_>>()?;
let vals: Vec<String> = obj
.values()
.map(|v| {
Ok(match v {
serde_json::Value::String(s) => {
crate::safety::sql_literal("db_json_to_sqlite", s)?
}
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => if *b { "1" } else { "0" }.to_string(),
serde_json::Value::Null => "NULL".to_string(),
nested => crate::safety::sql_literal("db_json_to_sqlite", &nested.to_string())?,
})
})
.collect::<Result<_>>()?;
inserts.push(format!(
"INSERT INTO {} ({}) VALUES ({})",
crate::safety::sql_identifier("db_json_to_sqlite", &table_name)?,
cols.join(", "),
vals.join(", ")
));
}
sqlite_exec_contained(vec![Value::Str(db_path.clone()), Value::Str(create_sql)])?;
for insert_sql in inserts {
sqlite_exec_contained(vec![Value::Str(db_path.clone()), Value::Str(insert_sql)])?;
}
Ok(Value::Bool(true))
}
fn bi_db_sqlite_to_json(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let db_path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_to_json",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let table = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"db_sqlite_to_json",
"String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
bi_db_sqlite_query(
vec![
Value::Str(db_path),
Value::Str(format!(
"SELECT * FROM {}",
crate::safety::sql_identifier("db_sqlite_to_json", &table)?
)),
],
None,
)
}
fn bi_db_kv_store(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// Initialize KV store
let store_path = args
.first()
.and_then(|v| match v {
Value::Str(s) => Some(s.clone()),
_ => None,
})
.unwrap_or_else(|| ".ironshell_kv.db".to_string());
// The result was discarded, so a store that could not be created was
// reported as created.
sqlite_exec_contained(vec![
Value::Str(store_path.clone()),
Value::Str("CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT)".to_string()),
])?;
Ok(Value::Str(store_path))
}
// =====================================================================
// ROBUST FILE EDITING BUILTINS (for AI agents)
// =====================================================================
// These functions provide structured, atomic file editing that avoids
// shell quoting/escaping issues that plague BASH and PowerShell.
// AI agents can safely manipulate multi-line text without worrying about
// special characters, newlines, or encoding problems.
// =====================================================================
/// file_write(path, content) - Write content to a file (creates or overwrites)
/// This is the foundation for safe file writing without shell escaping issues.
/// Usage: file_write("config.txt", "line1\nline2\nline3")
/// content | file_write("output.txt")
fn write_file_atomically(builtin: &str, path: &str, content: &[u8]) -> Result<()> {
use std::io::Write;
let requested = std::path::Path::new(path);
// A rename over a symlink would replace the link with a regular file. So a
// symlink is resolved first, and the write goes next to the file it names;
// the link keeps pointing at the updated file. The jail check already
// resolves links, so the resolved file is the one that was checked.
let resolved;
let target: &std::path::Path = match fs::symlink_metadata(requested) {
Ok(meta) if meta.file_type().is_symlink() => {
resolved = fs::canonicalize(requested)
.map_err(|error| crate::safety::fs_error(builtin, path, &error))?;
&resolved
}
_ => requested,
};
let parent = target
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| std::path::Path::new("."));
let temporary = parent.join(format!(".ironshell_tmp_{}", uuid::Uuid::new_v4()));
// Never reuse another writer's temporary file or follow a preexisting link.
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temporary)
.map_err(|error| crate::safety::fs_error(builtin, path, &error))?;
let original_permissions = file.metadata().ok().map(|m| m.permissions());
// The data must reach the disk before the rename publishes it, or a crash
// can leave an empty or partial file under the target name.
let written = file.write_all(content).and_then(|_| file.sync_all());
drop(file);
let result = written
.map_err(|error| crate::safety::fs_error(builtin, path, &error))
.and_then(|_| match fs::metadata(target) {
// An existing target keeps its mode (Unix) and read-only attribute (Windows).
Ok(existing) => fs::set_permissions(&temporary, existing.permissions())
.map_err(|error| crate::safety::fs_error(builtin, path, &error)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(crate::safety::fs_error(builtin, path, &error)),
})
.and_then(|_| {
fs::rename(&temporary, target)
.map_err(|error| crate::safety::fs_error(builtin, path, &error))
});
match result {
Ok(()) => {
sync_parent_directory(parent);
Ok(())
}
Err(error) => {
// Restore our own file's permissions before removing it: a read-only copy cannot be deleted on Windows.
if let Some(permissions) = original_permissions {
let _ = fs::set_permissions(&temporary, permissions);
}
let _ = fs::remove_file(&temporary);
Err(error)
}
}
}
/// Best-effort fsync of the directory that holds a just-renamed file, so the
/// rename itself survives a crash on Unix. Errors are ignored: the file is
/// already published, and some filesystems refuse to sync a directory.
#[cfg(unix)]
fn sync_parent_directory(parent: &std::path::Path) {
if let Ok(dir) = fs::File::open(parent) {
let _ = dir.sync_all();
}
}
/// Windows has no directory fsync in std, and the rename is already durable there.
#[cfg(not(unix))]
fn sync_parent_directory(_parent: &std::path::Path) {}
fn bi_file_write(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
if args.is_empty() {
return Err(crate::safety::arg_err("file_write requires a file path"));
}
let path = match &args[0] {
Value::Str(s) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"file_write",
"path: String",
other.type_name(),
))
}
};
// In a jailed context, a relative path is workspace-relative (not CWD-relative)
// so the write lands inside the jail and matches the transaction journal.
let path = crate::safety::resolve_path_str(&path);
// Get content from args or pipeline input
let content = if args.len() > 1 {
match &args[1] {
Value::Str(s) => s.clone(),
Value::Array(arr) => arr
.iter()
.map(|v| match v {
Value::Str(s) => s.clone(),
other => format!("{:?}", other),
})
.collect::<Vec<_>>()
.join("\n"),
other => format!("{:?}", other),
}
} else {
match input {
Some(Value::Str(s)) => s,
Some(Value::Array(arr)) => arr
.iter()
.map(|v| match v {
Value::Str(s) => s.clone(),
other => format!("{:?}", other),
})
.collect::<Vec<_>>()
.join("\n"),
Some(other) => format!("{:?}", other),
None => {
return Err(crate::safety::arg_err(
"file_write requires content to write",
))
}
}
};
// Agent mode / explicit workspace: a write is allowed but jailed to the
// workspace (WriteLocal is allow-by-policy; fs_paths enforces containment, so
// an absolute path outside the workspace is rejected).
crate::safety::guard(crate::safety::GuardCtx {
builtin: "file_write",
effect: crate::safety::Effect::WriteLocal,
what: "write",
targets: vec![path.clone()],
blast_radius: serde_json::json!({ "bytes": content.len() }),
reversible: true,
fs_paths: true,
})?;
crate::tx::snapshot(&path); // record pre-write state if a transaction is active
write_file_atomically("file_write", &path, content.as_bytes())?;
let mut result = BTreeMap::new();
result.insert("path".to_string(), Value::Str(path));
result.insert("bytes".to_string(), Value::Int(content.len() as i64));
result.insert(
"lines".to_string(),
Value::Int(content.lines().count() as i64),
);
result.insert("success".to_string(), Value::Bool(true));
Ok(Value::Record(result))
}
/// file_append(path, content) - Append content to a file
/// Usage: file_append("log.txt", "New log entry\n")
/// new_lines | file_append("data.txt")
fn bi_file_append(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
if args.is_empty() {
return Err(crate::safety::arg_err("file_append requires a file path"));
}
let path = match &args[0] {
Value::Str(s) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"file_append",
"path: String",
other.type_name(),
))
}
};
// Jailed: relative paths are workspace-relative (see file_write).
let path = crate::safety::resolve_path_str(&path);
// Get content from args or pipeline input
let content = if args.len() > 1 {
match &args[1] {
Value::Str(s) => s.clone(),
Value::Array(arr) => arr
.iter()
.map(|v| match v {
Value::Str(s) => s.clone(),
other => format!("{:?}", other),
})
.collect::<Vec<_>>()
.join("\n"),
other => format!("{:?}", other),
}
} else {
match input {
Some(Value::Str(s)) => s,
Some(Value::Array(arr)) => arr
.iter()
.map(|v| match v {
Value::Str(s) => s.clone(),
other => format!("{:?}", other),
})
.collect::<Vec<_>>()
.join("\n"),
Some(other) => format!("{:?}", other),
None => {
return Err(crate::safety::arg_err(
"file_append requires content to append",
))
}
}
};
// Jailed to the workspace in agent mode (see file_write).
crate::safety::guard(crate::safety::GuardCtx {
builtin: "file_append",
effect: crate::safety::Effect::WriteLocal,
what: "append",
targets: vec![path.clone()],
blast_radius: serde_json::json!({ "bytes": content.len() }),
reversible: true,
fs_paths: true,
})?;
crate::tx::snapshot(&path); // record pre-append state if a transaction is active
use std::io::Write;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|error| crate::safety::fs_error("file_append", &path, &error))?;
file.write_all(content.as_bytes())
.map_err(|error| crate::safety::fs_error("file_append", &path, &error))?;
let mut result = BTreeMap::new();
result.insert("path".to_string(), Value::Str(path));
result.insert(
"bytes_appended".to_string(),
Value::Int(content.len() as i64),
);
result.insert("success".to_string(), Value::Bool(true));
Ok(Value::Record(result))
}
/// file_patch(path, patches) - Apply multiple search/replace operations atomically
/// Patches is an array of records: [{find: "old", replace: "new"}, ...]
/// Usage: file_patch("config.txt", [{find: "DEBUG=0", replace: "DEBUG=1"}])
fn bi_file_patch(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
if args.is_empty() {
return Err(crate::safety::arg_err("file_patch requires a file path"));
}
let path = match &args[0] {
Value::Str(s) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"file_patch",
"path: String",
other.type_name(),
))
}
};
// Classified `ReadLocal` while the body wrote the file back in place.
// `WriteLocal` is what the jail keys on, so this path went uncontained.
let path = guard_local_write("file_patch", &path)?;
let patches = if args.len() > 1 {
match &args[1] {
Value::Array(arr) => arr.clone(),
Value::Record(r) => vec![Value::Record(r.clone())],
_ => {
return Err(crate::safety::arg_err(
"file_patch: patches must be an array of records",
))
}
}
} else {
match input {
Some(Value::Array(arr)) => arr,
Some(Value::Record(r)) => vec![Value::Record(r)],
_ => {
return Err(crate::safety::arg_err(
"file_patch: patches must be provided",
))
}
}
};
// Each patch is a full pass over the file, so the batch is capped the way
// file_edit caps its own.
if patches.len() > 10_000 {
return Err(crate::safety::budget_exceeded(
"file_patch",
"batch exceeds 10000 patches".into(),
"use a smaller batch",
));
}
// Refuse malformed instructions before reading or writing the file.
for (index, patch) in patches.iter().enumerate() {
let Value::Record(record) = patch else {
return Err(crate::safety::arg_err(format!(
"file_patch: patch {index} must be a record"
)));
};
for names in [["find", "old", "search"], ["replace", "new", "with"]] {
if !matches!(
record
.get(names[0])
.or(record.get(names[1]))
.or(record.get(names[2])),
Some(Value::Str(_))
) {
return Err(crate::safety::arg_err(format!(
"file_patch: patch {index} requires a {} string",
names[0]
)));
}
}
}
// Read current content
let mut content = read_text_capped("file_patch", &path)?;
let mut applied = Vec::new();
let mut failed = Vec::new();
for (i, patch) in patches.iter().enumerate() {
let record = match patch {
Value::Record(r) => r,
_ => {
failed.push(Value::Str(format!("patch {}: not a record", i)));
continue;
}
};
let find_str = match record
.get("find")
.or(record.get("old"))
.or(record.get("search"))
{
Some(Value::Str(s)) => s.clone(),
_ => {
failed.push(Value::Str(format!("patch {}: missing 'find' string", i)));
continue;
}
};
let replace_str = match record
.get("replace")
.or(record.get("new"))
.or(record.get("with"))
{
Some(Value::Str(s)) => s.clone(),
_ => {
failed.push(Value::Str(format!("patch {}: missing 'replace' string", i)));
continue;
}
};
if content.contains(&find_str) {
let count = content.matches(&find_str).count();
content = content.replace(&find_str, &replace_str);
let mut patch_result = BTreeMap::new();
patch_result.insert("find".to_string(), Value::Str(find_str));
patch_result.insert("replace".to_string(), Value::Str(replace_str));
patch_result.insert("count".to_string(), Value::Int(count as i64));
applied.push(Value::Record(patch_result));
} else {
failed.push(Value::Str(format!(
"patch {}: '{}' not found",
i,
if find_str.chars().nth(40).is_some() {
format!("{}...", find_str.chars().take(40).collect::<String>())
} else {
find_str
}
)));
}
}
// Write back atomically
crate::tx::snapshot(&path); // record pre-patch state if a transaction is active
write_file_atomically("file_patch", &path, content.as_bytes())?;
let mut result = BTreeMap::new();
result.insert("path".to_string(), Value::Str(path));
result.insert("applied".to_string(), Value::Array(applied.clone()));
result.insert("failed".to_string(), Value::Array(failed.clone()));
result.insert("success".to_string(), Value::Bool(failed.is_empty()));
result.insert(
"patches_applied".to_string(),
Value::Int(applied.len() as i64),
);
result.insert(
"patches_failed".to_string(),
Value::Int(failed.len() as i64),
);
Ok(Value::Record(result))
}
/// file_replace(path, old_text, new_text) - Simple string replacement in a file
/// Usage: file_replace("config.txt", "old_value", "new_value")
fn bi_file_replace(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
if args.len() < 3 {
return Err(crate::safety::arg_err(
"file_replace requires path, old_text, and new_text",
));
}
let path = match &args[0] {
Value::Str(s) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"file_replace",
"path: String",
other.type_name(),
))
}
};
let old_text = match &args[1] {
Value::Str(s) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"file_replace",
"old_text: String",
other.type_name(),
))
}
};
let new_text = match &args[2] {
Value::Str(s) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"file_replace",
"new_text: String",
other.type_name(),
))
}
};
let mut patch = BTreeMap::new();
patch.insert("find".to_string(), Value::Str(old_text));
patch.insert("replace".to_string(), Value::Str(new_text));
bi_file_patch(
vec![Value::Str(path), Value::Array(vec![Value::Record(patch)])],
None,
)
}
/// file_insert(path, position, content) - Insert content at a specific position
fn bi_file_insert(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
if args.len() < 2 {
return Err(crate::safety::arg_err(
"file_insert requires path and position",
));
}
let path = match &args[0] {
Value::Str(s) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"file_insert",
"path: String",
other.type_name(),
))
}
};
// Classified `ReadLocal` while the body wrote the file back in place.
// `WriteLocal` is what the jail keys on, so this path went uncontained.
let path = guard_local_write("file_insert", &path)?;
let content = if args.len() > 2 {
match &args[2] {
Value::Str(s) => s.clone(),
Value::Array(arr) => arr
.iter()
.map(|v| match v {
Value::Str(s) => s.clone(),
other => format!("{:?}", other),
})
.collect::<Vec<_>>()
.join("\n"),
other => format!("{:?}", other),
}
} else {
match input {
Some(Value::Str(s)) => s,
Some(Value::Array(arr)) => arr
.iter()
.map(|v| match v {
Value::Str(s) => s.clone(),
other => format!("{:?}", other),
})
.collect::<Vec<_>>()
.join("\n"),
Some(other) => format!("{:?}", other),
None => {
return Err(crate::safety::arg_err(
"file_insert requires content to insert",
))
}
}
};
let mut file_content = read_text_capped("file_insert", &path)?;
let mut lines: Vec<String> = file_content.lines().map(|s| s.to_string()).collect();
let insert_lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
let insert_pos: usize;
let mut pattern_found = true;
match &args[1] {
Value::Int(line_num) => {
let line = (*line_num).max(1) as usize;
insert_pos = (line - 1).min(lines.len());
}
Value::Str(pos) => match pos.as_str() {
"start" | "beginning" | "top" => insert_pos = 0,
"end" | "bottom" | "append" => insert_pos = lines.len(),
_ => {
if let Some(idx) = lines.iter().position(|l| l.contains(pos)) {
insert_pos = idx + 1;
} else {
pattern_found = false;
insert_pos = lines.len();
}
}
},
Value::Record(r) => {
if let Some(Value::Str(after_pattern)) = r.get("after") {
if let Some(idx) = lines.iter().position(|l| l.contains(after_pattern)) {
insert_pos = idx + 1;
} else {
pattern_found = false;
insert_pos = lines.len();
}
} else if let Some(Value::Str(before_pattern)) = r.get("before") {
if let Some(idx) = lines.iter().position(|l| l.contains(before_pattern)) {
insert_pos = idx;
} else {
pattern_found = false;
insert_pos = 0;
}
} else if let Some(Value::Int(line)) = r.get("line") {
insert_pos = ((*line).max(1) as usize - 1).min(lines.len());
} else {
return Err(crate::safety::arg_err(
"file_insert: position record must have 'after', 'before', or 'line' key",
));
}
}
_ => {
return Err(crate::safety::arg_err(
"file_insert: position must be a line number, string, or record",
))
}
}
for (i, line) in insert_lines.iter().enumerate() {
lines.insert(insert_pos + i, line.clone());
}
file_content = lines.join("\n");
if !file_content.ends_with('\n') {
file_content.push('\n');
}
crate::tx::snapshot(&path); // record pre-insert state if a transaction is active
write_file_atomically("file_insert", &path, file_content.as_bytes())?;
let mut result = BTreeMap::new();
result.insert("path".to_string(), Value::Str(path));
result.insert(
"lines_inserted".to_string(),
Value::Int(insert_lines.len() as i64),
);
result.insert("at_line".to_string(), Value::Int((insert_pos + 1) as i64));
result.insert("pattern_found".to_string(), Value::Bool(pattern_found));
result.insert("success".to_string(), Value::Bool(true));
Ok(Value::Record(result))
}
/// Delete an inclusive one-based range or lines selected by a literal pattern.
fn bi_file_delete_lines(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
if !(2..=3).contains(&args.len()) || input.is_some() {
return Err(crate::safety::arg_err("file_delete_lines requires path, line specification and optional range end, without pipeline input"));
}
enum Selection {
Range(usize, usize),
Pattern(String, bool),
}
fn line_index(value: Value) -> Result<usize> {
match value {
Value::Int(n) if n > 0 => usize::try_from(n - 1)
.map_err(|_| crate::safety::arg_err("file_delete_lines line number is too large")),
_ => Err(crate::safety::arg_err(
"file_delete_lines line numbers must be positive Int",
)),
}
}
let mut args = args.into_iter();
let Some(Value::Str(path)) = args.next() else {
return Err(crate::safety::arg_err(
"file_delete_lines path must be String",
));
};
let selection = match args.next() {
Some(value @ Value::Int(_)) => {
let start = line_index(value)?;
let end = args.next().map(line_index).transpose()?.unwrap_or(start);
if end < start {
return Err(crate::safety::arg_err(
"file_delete_lines range end must not precede start",
));
}
Selection::Range(start, end)
}
Some(Value::Record(record)) => {
if args.next().is_some() || record.len() != 1 {
return Err(crate::safety::arg_err("file_delete_lines pattern record requires exactly one selector and no range end"));
}
let (key, value) = record.into_iter().next().expect("one selector checked");
if !matches!(key.as_str(), "matching" | "contains" | "pattern" | "exact") {
return Err(crate::safety::arg_err(
"file_delete_lines selector must be matching, contains, pattern or exact",
));
}
let Value::Str(pattern) = value else {
return Err(crate::safety::arg_err(
"file_delete_lines pattern must be String",
));
};
Selection::Pattern(pattern, key == "exact")
}
_ => {
return Err(crate::safety::arg_err(
"file_delete_lines selection must be positive Int or pattern Record",
))
}
};
let path = crate::safety::resolve_path_str(&path);
crate::safety::guard(crate::safety::GuardCtx {
builtin: "file_delete_lines",
effect: crate::safety::Effect::Destructive,
what: "delete lines",
targets: vec![path.clone()],
blast_radius: serde_json::json!({"path":path}),
reversible: true,
fs_paths: true,
})?;
let content = line_read("file_delete_lines", &path)?;
let mut new_content = String::with_capacity(content.len());
let mut deleted = 0;
let mut remaining = 0;
for (index, line) in content.lines().enumerate() {
let remove = match &selection {
Selection::Range(start, end) => (*start..=*end).contains(&index),
Selection::Pattern(pattern, exact) => {
if *exact {
line == pattern
} else {
line.contains(pattern)
}
}
};
if remove {
deleted += 1;
} else {
new_content.push_str(line);
new_content.push('\n');
remaining += 1;
}
}
crate::tx::snapshot(&path);
write_file_atomically("file_delete_lines", &path, new_content.as_bytes())?;
Ok(Value::Record(BTreeMap::from([
("path".into(), Value::Str(path)),
("lines_deleted".into(), Value::Int(deleted)),
("lines_remaining".into(), Value::Int(remaining)),
("success".into(), Value::Bool(true)),
])))
}
/// Apply a validated batch in descending line order, then publish once.
fn bi_file_edit(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const MAX_BYTES: usize = 64 * 1024 * 1024;
const MAX_LINES: usize = 1_000_000;
if (input.is_some() && args.len() != 1) || (input.is_none() && args.len() != 2) {
return Err(crate::safety::arg_err(
"file_edit requires path and direct or piped edits, without conflicting input",
));
}
let mut args = args.into_iter();
let Some(Value::Str(path)) = args.next() else {
return Err(crate::safety::arg_err("file_edit path must be String"));
};
let edits = match input.or_else(|| args.next()) {
Some(Value::Array(edits)) => edits,
Some(edit @ Value::Record(_)) => vec![edit],
_ => {
return Err(crate::safety::arg_err(
"file_edit edits must be a Record or Array of Records",
))
}
};
if edits.len() > 10_000 {
return Err(crate::safety::budget_exceeded(
"file_edit",
"batch exceeds 10000 edits".into(),
"use a smaller batch",
));
}
fn field(
record: &mut BTreeMap<String, Value>,
name: &str,
alias: &str,
) -> Result<Option<Value>> {
let first = record.remove(name);
let second = record.remove(alias);
if first.is_some() && second.is_some() {
return Err(crate::safety::arg_err(format!(
"file_edit cannot combine {name} and {alias}"
)));
}
Ok(first.or(second))
}
fn position(value: Option<Value>) -> Result<usize> {
match value {
Some(Value::Int(n)) if n > 0 => usize::try_from(n - 1)
.map_err(|_| crate::safety::arg_err("file_edit line number is too large")),
_ => Err(crate::safety::arg_err(
"file_edit line numbers must be positive Int",
)),
}
}
struct Edit {
index: usize,
op: &'static str,
start: usize,
end: usize,
lines: Vec<String>,
}
let mut prepared = Vec::with_capacity(edits.len());
let mut payload_bytes = 0usize;
let mut payload_lines = 0usize;
for (index, edit) in edits.into_iter().enumerate() {
let Value::Record(mut record) = edit else {
return Err(crate::safety::arg_err(
"file_edit every edit must be a Record",
));
};
let op = match field(&mut record, "op", "operation")? {
Some(Value::Str(op)) => match op.as_str() {
"replace" | "replace_range" => "replace_range",
"insert" | "insert_at" => "insert_at",
"delete" | "delete_range" => "delete_range",
_ => return Err(crate::safety::arg_err("file_edit unknown operation")),
},
_ => {
return Err(crate::safety::arg_err(
"file_edit requires an operation String",
))
}
};
let start = if op == "insert_at" {
position(record.remove("line"))?
} else {
position(field(&mut record, "start_line", "line")?)?
};
let end = if op == "insert_at" {
start
} else {
record
.remove("end_line")
.map(|v| position(Some(v)))
.transpose()?
.unwrap_or(start)
};
if end < start {
return Err(crate::safety::arg_err(
"file_edit range end must not precede start",
));
}
let lines = if op == "delete_range" {
Vec::new()
} else {
let Some(Value::Str(text)) = field(&mut record, "content", "text")? else {
return Err(crate::safety::arg_err(
"file_edit insert and replace require content String",
));
};
payload_bytes = payload_bytes.saturating_add(text.len());
payload_lines = payload_lines.saturating_add(text.lines().count());
if payload_bytes > MAX_BYTES || payload_lines > MAX_LINES {
return Err(crate::safety::budget_exceeded(
"file_edit",
"edit payload exceeds 64 MiB or one million lines".into(),
"use a smaller batch",
));
}
text.lines().map(str::to_owned).collect()
};
if !record.is_empty() {
return Err(crate::safety::arg_err(
"file_edit unknown or conflicting operation fields",
));
}
prepared.push(Edit {
index,
op,
start,
end,
lines,
});
}
let path = guard_local_write("file_edit", &path)?;
let content = line_read("file_edit", &path)?;
let count = content.lines().count();
let max_lines = count.saturating_add(payload_lines);
if max_lines > MAX_LINES || max_lines.saturating_mul(prepared.len()) > 10_000_000 {
return Err(crate::safety::budget_exceeded(
"file_edit",
"batch exceeds one million potential lines or ten million line-edit work units".into(),
"use a smaller file or batch",
));
}
let mut lines: Vec<String> = content.lines().map(str::to_owned).collect();
drop(content);
prepared.sort_by(|a, b| b.start.cmp(&a.start));
let mut applied = Vec::with_capacity(prepared.len());
for edit in prepared {
let mut result = BTreeMap::from([("op".into(), Value::Str(edit.op.into()))]);
if edit.op == "insert_at" {
let at = edit.start.min(lines.len());
let inserted = edit.lines.len();
lines.splice(at..at, edit.lines);
result.insert("line".into(), Value::Int((at + 1) as i64));
result.insert("lines_inserted".into(), Value::Int(inserted as i64));
} else {
if edit.start >= lines.len() {
return Err(crate::safety::arg_err(format!(
"file_edit edit {}: start line out of range",
edit.index
)));
}
let end = edit.end.min(lines.len() - 1);
result.insert("start_line".into(), Value::Int((edit.start + 1) as i64));
if edit.op == "replace_range" {
lines.splice(edit.start..=end, edit.lines);
result.insert("end_line".into(), Value::Int((end + 1) as i64));
} else {
lines.drain(edit.start..=end);
result.insert(
"lines_deleted".into(),
Value::Int((end - edit.start + 1) as i64),
);
}
}
applied.push(Value::Record(result));
}
let bytes = lines.iter().fold(0usize, |n, line| {
n.saturating_add(line.len()).saturating_add(1)
});
if bytes > MAX_BYTES {
return Err(crate::safety::budget_exceeded(
"file_edit",
"normalized output exceeds 64 MiB".into(),
"produce a smaller file",
));
}
let mut output = String::with_capacity(bytes);
for line in &lines {
output.push_str(line);
output.push('\n');
}
crate::tx::snapshot(&path);
write_file_atomically("file_edit", &path, output.as_bytes())?;
let applied_count = applied.len();
Ok(Value::Record(BTreeMap::from([
("path".into(), Value::Str(path)),
("applied".into(), Value::Array(applied)),
("failed".into(), Value::Array(Vec::new())),
("success".into(), Value::Bool(true)),
("edits_applied".into(), Value::Int(applied_count as i64)),
("edits_failed".into(), Value::Int(0)),
("final_lines".into(), Value::Int(lines.len() as i64)),
])))
}
/// file_diff(path1, path2) - Compare two files and return differences
fn bi_file_diff(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
if input.is_some() || args.len() != 2 {
return Err(crate::safety::arg_err(
"file_diff requires two direct file paths and no pipeline input",
));
}
let mut args = args.into_iter();
let Some(Value::Str(path1)) = args.next() else {
return Err(crate::safety::arg_err(
"file_diff first path must be String",
));
};
let Some(Value::Str(path2)) = args.next() else {
return Err(crate::safety::arg_err(
"file_diff second path must be String",
));
};
let resolved1 = crate::safety::resolve_path_str(&path1);
let resolved2 = crate::safety::resolve_path_str(&path2);
crate::safety::guard(crate::safety::GuardCtx {
builtin: "file_diff",
effect: crate::safety::Effect::ReadLocal,
what: "compare files",
targets: vec![resolved1.clone(), resolved2.clone()],
blast_radius: serde_json::json!({"files":2}),
reversible: true,
fs_paths: true,
})?;
let content1 = line_read("file_diff", &resolved1)?;
let content2 = line_read("file_diff", &resolved2)?;
let mut lines1 = content1.lines();
let mut lines2 = content2.lines();
let (mut count1, mut count2, mut line) = (0, 0, 0);
let mut diffs = Vec::new();
loop {
let old = lines1.next();
let new = lines2.next();
if old.is_none() && new.is_none() {
break;
}
line += 1;
count1 += usize::from(old.is_some());
count2 += usize::from(new.is_some());
if old == new {
continue;
}
if diffs.len() >= 10000 {
return Err(crate::safety::budget_exceeded(
"file_diff",
"comparison exceeds 10000 differences".into(),
"compare smaller files",
));
}
let kind = match (old, new) {
(Some(_), Some(_)) => "changed",
(Some(_), None) => "deleted",
(None, Some(_)) => "added",
_ => unreachable!(),
};
let mut diff = BTreeMap::from([
("line".into(), Value::Int(line)),
("type".into(), Value::Str(kind.into())),
]);
if let Some(old) = old {
diff.insert("old".into(), Value::Str(old.to_string()));
}
if let Some(new) = new {
diff.insert("new".into(), Value::Str(new.to_string()));
}
diffs.push(Value::Record(diff));
}
let diff_count = diffs.len();
Ok(Value::Record(BTreeMap::from([
("file1".into(), Value::Str(path1)),
("file2".into(), Value::Str(path2)),
("lines1".into(), Value::Int(count1 as i64)),
("lines2".into(), Value::Int(count2 as i64)),
("differences".into(), Value::Array(diffs)),
("diff_count".into(), Value::Int(diff_count as i64)),
("identical".into(), Value::Bool(diff_count == 0)),
])))
}
/// file_backup(path, [suffix]) - Create a backup copy of a file before editing
/// Usage: file_backup("config.txt") -> creates config.txt.bak
/// Usage: file_backup("config.txt", ".backup") -> creates config.txt.backup
fn bi_file_backup(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let mut args = args.into_iter();
let path = match input {
Some(Value::Str(path)) => path,
Some(_) => {
return Err(crate::safety::arg_err(
"file_backup piped path must be String",
))
}
None => match args.next() {
Some(Value::Str(path)) => path,
_ => return Err(crate::safety::arg_err("file_backup requires path String")),
},
};
let suffix = match args.next() {
None => ".bak".to_string(),
Some(Value::Str(suffix)) => suffix,
Some(_) => return Err(crate::safety::arg_err("file_backup suffix must be String")),
};
if args.next().is_some() {
return Err(crate::safety::arg_err(
"file_backup accepts path and optional suffix only",
));
}
if suffix.is_empty()
|| suffix.ends_with(['.', ' '])
|| suffix
.chars()
.any(|c| c.is_control() || matches!(c, '/' | '\\' | ':'))
{
return Err(crate::safety::arg_err("file_backup suffix must be nonempty, without separators, colon, controls or trailing dot/space"));
}
let backup = crate::safety::resolve_path_str(&format!("{path}{suffix}"));
let path = crate::safety::resolve_path_str(&path);
crate::safety::guard(crate::safety::GuardCtx {
builtin: "file_backup",
effect: crate::safety::Effect::WriteLocal,
what: "back up file",
targets: vec![path.clone(), backup.clone()],
blast_radius: serde_json::json!({"files":2}),
reversible: true,
fs_paths: true,
})?;
let bytes = backup_file_atomically(&path, &backup)?;
Ok(Value::Record(BTreeMap::from([
("path".into(), Value::Str(path)),
("backup".into(), Value::Str(backup)),
("bytes".into(), Value::Int(bytes as i64)),
("success".into(), Value::Bool(true)),
])))
}
/// Copy binary bytes through an exclusively created sibling, then publish it.
fn backup_file_atomically(source: &str, destination: &str) -> Result<u64> {
copy_file_atomically("file_backup", source, destination, 64 * 1024 * 1024)
}
fn copy_file_atomically(
builtin: &str,
source: &str,
destination: &str,
max_bytes: u64,
) -> Result<u64> {
use std::io::Read;
let metadata =
fs::metadata(source).map_err(|e| crate::safety::fs_error(builtin, source, &e))?;
if !metadata.is_file() {
return Err(crate::safety::arg_err(format!(
"{builtin} source must be a regular file"
)));
}
if metadata.len() > max_bytes {
return Err(crate::safety::budget_exceeded(
builtin,
"source exceeds remaining copy byte budget".into(),
"copy a smaller file",
));
}
match fs::metadata(destination) {
Ok(metadata) if !metadata.is_file() => {
return Err(crate::safety::arg_err(format!(
"{builtin} destination must be a regular file when it exists"
)))
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(crate::safety::fs_error(builtin, destination, &error)),
}
if let Ok(source_path) = fs::canonicalize(source) {
if fs::canonicalize(destination)
.is_ok_and(|destination_path| destination_path == source_path)
{
return Err(crate::safety::arg_err(format!(
"{builtin} destination must differ from source"
)));
}
}
let file = fs::File::open(source).map_err(|e| crate::safety::fs_error(builtin, source, &e))?;
let target = std::path::Path::new(destination);
let parent = target
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| std::path::Path::new("."));
let temporary = parent.join(format!(".ironshell_tmp_{}", uuid::Uuid::new_v4()));
let mut output = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temporary)
.map_err(|e| crate::safety::fs_error(builtin, destination, &e))?;
let original_permissions = output.metadata().ok().map(|m| m.permissions());
let result = (|| -> Result<u64> {
let bytes = std::io::copy(&mut file.take(max_bytes + 1), &mut output)
.map_err(|e| crate::safety::fs_error(builtin, destination, &e))?;
if bytes > max_bytes {
return Err(crate::safety::budget_exceeded(
builtin,
"source grew beyond remaining copy byte budget".into(),
"copy a smaller file",
));
}
output
.set_permissions(metadata.permissions())
.map_err(|e| crate::safety::fs_error(builtin, destination, &e))?;
drop(output);
crate::tx::snapshot(destination);
fs::rename(&temporary, target)
.map_err(|e| crate::safety::fs_error(builtin, destination, &e))?;
Ok(bytes)
})();
if result.is_err() {
// Restore the original sibling-file permissions before cleaning our own file.
// This also removes Windows read-only attributes copied from the source.
if let Some(permissions) = original_permissions {
let _ = fs::set_permissions(&temporary, permissions);
}
let _ = fs::remove_file(&temporary);
}
result
}
/// Copy a bounded, preflighted file or directory tree.
fn bi_file_copy(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const MAX_BYTES: u64 = 64 * 1024 * 1024;
const MAX_ENTRIES: usize = 10_000;
if args.len() != 2 || input.is_some() {
return Err(crate::safety::arg_err(
"file_copy requires exactly source and destination paths, without pipeline input",
));
}
let mut args = args.into_iter();
let Some(Value::Str(source)) = args.next() else {
return Err(crate::safety::arg_err("file_copy source must be String"));
};
let Some(Value::Str(dest)) = args.next() else {
return Err(crate::safety::arg_err(
"file_copy destination must be String",
));
};
let source = crate::safety::resolve_path_str(&source);
let dest = crate::safety::resolve_path_str(&dest);
let source_path = std::path::Path::new(&source);
let dest_path = std::path::Path::new(&dest);
let metadata = fs::symlink_metadata(source_path)
.map_err(|e| crate::safety::fs_error("file_copy", source_path, &e))?;
if !metadata.is_file() && !metadata.is_dir() {
return Err(crate::safety::arg_err(
"file_copy supports regular files and directories only, without symlink entries",
));
}
// Canonicalize the existing parent before joining the leaf: this also makes
// non-existent destinations comparable with the canonical source tree.
let parent = dest_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| std::path::Path::new("."));
let parent =
fs::canonicalize(parent).map_err(|e| crate::safety::fs_error("file_copy", parent, &e))?;
let leaf = dest_path.file_name().ok_or_else(|| {
crate::safety::arg_err("file_copy destination must name a file or directory")
})?;
if !parent.is_dir() {
return Err(crate::safety::arg_err(
"file_copy destination parent must be an existing directory",
));
}
let canonical_dest = match fs::canonicalize(dest_path) {
Ok(path) => path,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => parent.join(leaf),
Err(e) => return Err(crate::safety::fs_error("file_copy", dest_path, &e)),
};
let canonical_source = fs::canonicalize(source_path)
.map_err(|e| crate::safety::fs_error("file_copy", source_path, &e))?;
if canonical_source == canonical_dest
|| (metadata.is_dir()
&& (canonical_dest.starts_with(&canonical_source)
|| canonical_source.starts_with(&canonical_dest)))
{
return Err(crate::safety::arg_err(
"file_copy source and destination must not overlap",
));
}
// Source reads are intentionally unjailed. Only destination paths are guarded.
let mut plan = vec![(
source_path.to_path_buf(),
dest_path.to_path_buf(),
metadata.is_dir(),
)];
let mut bytes = if metadata.is_file() {
metadata.len()
} else {
0
};
if metadata.is_dir() {
for entry in WalkDir::new(source_path).min_depth(1) {
if plan.len() >= MAX_ENTRIES {
return Err(crate::safety::budget_exceeded(
"file_copy",
"copy exceeds 10000 entries".into(),
"copy a smaller tree",
));
}
let entry = entry.map_err(|e| {
crate::safety::fs_error(
"file_copy",
e.path().unwrap_or(source_path),
&std::io::Error::other(e.to_string()),
)
})?;
let kind = entry.file_type();
if entry.path().to_str().is_none() {
return Err(crate::safety::arg_err(
"file_copy tree paths must be valid UTF-8",
));
}
if !kind.is_file() && !kind.is_dir() {
return Err(crate::safety::arg_err("file_copy tree must contain regular files and directories only, without symlink entries"));
}
let relative = entry
.path()
.strip_prefix(source_path)
.map_err(|_| crate::safety::arg_err("file_copy traversal escaped source"))?;
if kind.is_file() {
let size = fs::metadata(entry.path())
.map_err(|e| crate::safety::fs_error("file_copy", entry.path(), &e))?
.len();
bytes = bytes.saturating_add(size);
}
plan.push((
entry.path().to_path_buf(),
dest_path.join(relative),
kind.is_dir(),
));
if bytes > MAX_BYTES {
break;
}
}
}
if bytes > MAX_BYTES {
return Err(crate::safety::budget_exceeded(
"file_copy",
"copy exceeds 64 MiB total".into(),
"copy smaller files",
));
}
// Refuse conflicts and symlink destinations for the entire plan before any
// mkdir, transaction snapshot or copy. Existing directories are merged.
for (_, target, directory) in &plan {
match fs::symlink_metadata(target) {
Ok(m) if (*directory && !m.is_dir()) || (!*directory && !m.is_file()) => {
return Err(crate::safety::arg_err(
"file_copy destination type conflicts with source or is a symlink",
));
}
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(crate::safety::fs_error("file_copy", target, &e)),
}
}
crate::safety::guard(crate::safety::GuardCtx {
builtin: "file_copy",
effect: crate::safety::Effect::WriteLocal,
what: "copy files",
targets: plan
.iter()
.map(|(_, p, _)| p.to_string_lossy().into_owned())
.collect(),
blast_radius: serde_json::json!({"entries":plan.len(),"bytes":bytes}),
reversible: true,
fs_paths: true,
})?;
let mut copied = 0;
for (from, to, directory) in plan {
let to_str = to.to_string_lossy();
if directory {
// Snapshot only new directories; snapshotting an existing merge
// root would also journal unrelated files in that directory.
if !to.exists() {
crate::tx::snapshot(&to_str);
fs::create_dir(&to).map_err(|e| crate::safety::fs_error("file_copy", &to, &e))?;
}
} else {
copied += copy_file_atomically(
"file_copy",
&from.to_string_lossy(),
&to_str,
MAX_BYTES - copied,
)?;
}
}
Ok(Value::Record(BTreeMap::from([
("source".into(), Value::Str(source)),
("dest".into(), Value::Str(dest)),
("bytes".into(), Value::Int(copied as i64)),
("success".into(), Value::Bool(true)),
])))
}
/// file_move(source, dest) - Move/rename a file or directory
fn bi_file_move(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
if args.len() < 2 {
return Err(crate::safety::arg_err(
"file_move requires source and destination paths",
));
}
let source = match &args[0] {
Value::Str(s) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"file_move",
"source: String",
other.type_name(),
))
}
};
let dest = match &args[1] {
Value::Str(s) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"file_move",
"destination: String",
other.type_name(),
))
}
};
// Probed where the move will look: in agent mode a relative source is in
// the workspace, and probing the bare name asked about the working
// directory instead, so a file the move would have found was not found.
if !std::path::Path::new(&crate::safety::resolve_path_str(&source)).exists() {
return Err(crate::safety::not_found("file_move", "source", &source));
}
// Classified `ReadLocal` while the body renamed one path over another —
// which removes the source and can overwrite the destination. Both ends
// are caller-supplied, and neither was jailed.
let source = guard_local_move("file_move", &source)?;
let dest = guard_local_move("file_move", &dest)?;
let metadata =
fs::metadata(&source).map_err(|e| crate::safety::fs_error("file_move", &source, &e))?;
let bytes = metadata.len();
// Both ends change: the source disappears and the destination is replaced
// (or created). Rollback restores the source and removes or restores the
// destination.
crate::tx::snapshot(&source);
crate::tx::snapshot(&dest);
fs::rename(&source, &dest)
.map_err(|error| crate::safety::fs_error("file_move", &dest, &error))?;
let mut result = BTreeMap::new();
result.insert("source".to_string(), Value::Str(source));
result.insert("dest".to_string(), Value::Str(dest));
result.insert("bytes".to_string(), Value::Int(bytes as i64));
result.insert("success".to_string(), Value::Bool(true));
Ok(Value::Record(result))
}
/// file_exists(path) - Check if a file or directory exists
fn bi_file_exists(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let path = match args.first().or(input.as_ref()) {
Some(Value::Str(s)) => s.clone(),
_ => return Err(crate::safety::arg_err("file_exists requires a path")),
};
// Path::exists/is_file/is_dir answer false for any stat error, so a path
// that could not be checked read as absent. Only NotFound is absent now.
let p = std::path::Path::new(&path);
let meta = match std::fs::metadata(p) {
Ok(meta) => Some(meta),
Err(e) if path_is_absent(&e) => None,
Err(e) => return Err(crate::safety::fs_error("file_exists", p, &e)),
};
let exists = meta.is_some();
let is_file = meta.as_ref().is_some_and(|m| m.is_file());
let is_dir = meta.as_ref().is_some_and(|m| m.is_dir());
let mut result = BTreeMap::new();
result.insert("path".to_string(), Value::Str(path));
result.insert("exists".to_string(), Value::Bool(exists));
result.insert("is_file".to_string(), Value::Bool(is_file));
result.insert("is_dir".to_string(), Value::Bool(is_dir));
Ok(Value::Record(result))
}
/// file_mkdir(path) - Create a directory (and parents if needed)
fn bi_file_mkdir(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let path = match args.first().or(input.as_ref()) {
Some(Value::Str(s)) => s.clone(),
_ => return Err(crate::safety::arg_err("file_mkdir requires a path")),
};
// Creates a directory at a caller-named path, with no guard until now:
// `WriteLocal` is not centrally enforced, so nothing jailed it.
let path = guard_local_write("file_mkdir", &path)?;
let already_exists = std::path::Path::new(&path).exists();
// Inside a transaction, record the highest directory this call creates as
// absent, so a rollback removes everything it made, parents included.
if !already_exists {
let mut top = std::path::Path::new(&path);
while let Some(parent) = top.parent() {
if parent.as_os_str().is_empty() || parent.exists() {
break;
}
top = parent;
}
crate::tx::snapshot(&top.to_string_lossy());
}
fs::create_dir_all(&path)
.map_err(|error| crate::safety::fs_error("file_mkdir", &path, &error))?;
let mut result = BTreeMap::new();
result.insert("path".to_string(), Value::Str(path));
result.insert("created".to_string(), Value::Bool(!already_exists));
result.insert("success".to_string(), Value::Bool(true));
Ok(Value::Record(result))
}
// ===================== A2A (Agent-to-Agent) Protocol =====================
lazy_static::lazy_static! {
/// A2A agent registry - maps agent names to their connection info
static ref A2A_REGISTRY: std::sync::RwLock<BTreeMap<String, BTreeMap<String, Value>>> = {
std::sync::RwLock::new(BTreeMap::new())
};
/// A2A message queue - pending messages for each agent
static ref A2A_MESSAGES: std::sync::RwLock<BTreeMap<String, Vec<Value>>> = {
std::sync::RwLock::new(BTreeMap::new())
};
}
/// Most undelivered messages one agent's queue holds.
const A2A_QUEUE_MAX: usize = 1024;
/// Largest message a2a_send accepts, measured as its display text.
const A2A_MESSAGE_MAX: usize = 64 * 1024;
/// Most agents one process registers (the registry was unbounded).
const A2A_AGENTS_MAX: usize = 256;
/// Most agents one a2a_broadcast reaches in a single call.
const A2A_BROADCAST_RECIPIENTS_MAX: usize = 64;
/// Largest info Record a2a_register stores, measured as its display text.
const A2A_INFO_MAX: usize = 64 * 1024;
/// a2a.send(target, message) - Queue a message for a registered agent. The
/// queue is in-process: nothing leaves the process, so it needs no network guard.
fn bi_a2a_send(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped_subject("a2a_send", &input)?;
let target = collab_text_arg("a2a_send", "target", args.first(), 128)?;
let message = args
.get(1)
.cloned()
.ok_or_else(|| crate::safety::bad_arg("a2a_send", "message: any value", "nothing"))?;
let size = message.to_display_string().len();
if size > A2A_MESSAGE_MAX {
return Err(crate::safety::budget_exceeded(
"a2a_send",
format!("a2a_send: the message is {size} bytes; at most {A2A_MESSAGE_MAX} are sent"),
"send a reference to the data instead of the data",
));
}
let registry = A2A_REGISTRY
.read()
.map_err(|_| crate::safety::lock_poisoned("a2a_send", "the A2A agent registry"))?;
if !registry.contains_key(&target) {
return Err(crate::safety::not_found("a2a_send", "agent", &target));
}
drop(registry);
let mut messages = A2A_MESSAGES
.write()
.map_err(|_| crate::safety::lock_poisoned("a2a_send", "the A2A message queue"))?;
let queue = messages.entry(target.clone()).or_default();
if queue.len() >= A2A_QUEUE_MAX {
return Err(crate::safety::budget_exceeded(
"a2a_send",
format!("a2a_send: {target} has {A2A_QUEUE_MAX} undelivered messages"),
"a2a_receive drains an agent's queue",
));
}
queue.push(message);
let mut result = BTreeMap::new();
result.insert("sent".to_string(), Value::Bool(true));
result.insert("target".to_string(), Value::Str(target));
Ok(Value::Record(result))
}
/// a2a.receive(agent_name?) - Receive pending messages
fn bi_a2a_receive(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let agent_name = args
.first()
.and_then(|v| v.as_str().ok())
.map(|s| s.to_string())
.unwrap_or_else(|| "default".to_string());
let mut messages = A2A_MESSAGES.write().unwrap();
let pending = messages.remove(&agent_name).unwrap_or_default();
Ok(Value::Array(pending))
}
/// a2a.broadcast(message) - Broadcast to all agents. All or nothing: a refusal
/// (too large, too many recipients, a full queue) leaves every queue as it was.
fn bi_a2a_broadcast(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped_subject("a2a_broadcast", &input)?;
if args.is_empty() {
return Err(crate::safety::arg_err("a2a.broadcast: requires a message"));
}
if args.len() > 1 {
return Err(crate::safety::bad_arg(
"a2a_broadcast",
"one message",
&format!("{} arguments", args.len()),
));
}
let message = args[0].clone();
let size = message.to_display_string().len();
if size > A2A_MESSAGE_MAX {
return Err(crate::safety::budget_exceeded(
"a2a_broadcast",
format!("the message is {size} bytes; at most {A2A_MESSAGE_MAX} are sent"),
"send a reference to the data instead of the data",
));
}
let registry = A2A_REGISTRY
.read()
.map_err(|_| crate::safety::lock_poisoned("a2a_broadcast", "the A2A agent registry"))?;
let agents: Vec<String> = registry.keys().cloned().collect();
drop(registry);
if agents.len() > A2A_BROADCAST_RECIPIENTS_MAX {
return Err(crate::safety::budget_exceeded(
"a2a_broadcast",
format!(
"{} agents are registered; a broadcast reaches at most {A2A_BROADCAST_RECIPIENTS_MAX}",
agents.len()
),
"send to the agents by name with a2a_send",
));
}
let mut messages = A2A_MESSAGES
.write()
.map_err(|_| crate::safety::lock_poisoned("a2a_broadcast", "the A2A message queue"))?;
// Check every recipient's queue before pushing to any of them.
for agent in &agents {
if messages.get(agent).map_or(0, Vec::len) >= A2A_QUEUE_MAX {
return Err(crate::safety::budget_exceeded(
"a2a_broadcast",
format!("{agent} has {A2A_QUEUE_MAX} undelivered messages"),
"a2a_receive drains an agent's queue",
));
}
}
for agent in &agents {
messages
.entry(agent.clone())
.or_default()
.push(message.clone());
}
let mut result = BTreeMap::new();
result.insert("broadcast".to_string(), Value::Bool(true));
result.insert("recipients".to_string(), Value::Int(agents.len() as i64));
Ok(Value::Record(result))
}
/// a2a.discover() - Discover available agents
fn bi_a2a_discover(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let registry = A2A_REGISTRY.read().unwrap();
let agents: Vec<Value> = registry
.iter()
.map(|(name, info)| {
let mut agent = info.clone();
agent.insert("name".to_string(), Value::Str(name.clone()));
Value::Record(agent)
})
.collect();
Ok(Value::Array(agents))
}
/// a2a.register(name, info?) - Register an agent
fn bi_a2a_register(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped_subject("a2a_register", &input)?;
if args.is_empty() {
return Err(crate::safety::arg_err("a2a.register: requires agent name"));
}
if args.len() > 2 {
return Err(crate::safety::bad_arg(
"a2a_register",
"a name and an optional info Record",
&format!("{} arguments", args.len()),
));
}
let name = collab_text_arg("a2a_register", "name", args.first(), 128)?;
// An info that is not a Record used to be replaced by an empty one without
// a word. Refuse it instead.
let info = match args.get(1) {
None | Some(Value::Null) => BTreeMap::new(),
Some(Value::Record(rec)) => rec.clone(),
Some(other) => {
return Err(crate::safety::bad_arg(
"a2a_register",
"info: a Record",
other.type_name(),
))
}
};
let info_size = Value::Record(info.clone()).to_display_string().len();
if info_size > A2A_INFO_MAX {
return Err(crate::safety::budget_exceeded(
"a2a_register",
format!("info is {info_size} bytes; at most {A2A_INFO_MAX} are kept"),
"register a name and a reference to the details",
));
}
let mut registry = A2A_REGISTRY
.write()
.map_err(|_| crate::safety::lock_poisoned("a2a_register", "the A2A agent registry"))?;
// Re-registering a name replaces its info and does not grow the registry.
if !registry.contains_key(&name) && registry.len() >= A2A_AGENTS_MAX {
return Err(crate::safety::budget_exceeded(
"a2a_register",
format!("{A2A_AGENTS_MAX} agents are already registered"),
"a2a_unregister an agent that is no longer running",
));
}
registry.insert(name.clone(), info);
let mut result = BTreeMap::new();
result.insert("registered".to_string(), Value::Bool(true));
result.insert("name".to_string(), Value::Str(name.to_string()));
Ok(Value::Record(result))
}
/// a2a.unregister(name) - Unregister an agent
fn bi_a2a_unregister(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
if args.is_empty() {
return Err(crate::safety::arg_err(
"a2a.unregister: requires agent name",
));
}
let name = args[0]
.as_str()
.map_err(|_| crate::safety::arg_err("a2a.unregister: name must be a string"))?;
let mut registry = A2A_REGISTRY.write().unwrap();
let removed = registry.remove(name).is_some();
let mut messages = A2A_MESSAGES.write().unwrap();
messages.remove(name);
let mut result = BTreeMap::new();
result.insert("unregistered".to_string(), Value::Bool(removed));
result.insert("name".to_string(), Value::Str(name.to_string()));
Ok(Value::Record(result))
}
/// a2a.status() - Get A2A subsystem status
fn bi_a2a_status(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let registry = A2A_REGISTRY.read().unwrap();
let messages = A2A_MESSAGES.read().unwrap();
let pending_count: usize = messages.values().map(|v| v.len()).sum();
let mut result = BTreeMap::new();
result.insert("active".to_string(), Value::Bool(true));
result.insert("agents".to_string(), Value::Int(registry.len() as i64));
result.insert(
"pending_messages".to_string(),
Value::Int(pending_count as i64),
);
Ok(Value::Record(result))
}
/// a2a.agents() - List all registered agents
fn bi_a2a_agents(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let registry = A2A_REGISTRY.read().unwrap();
let agents: Vec<Value> = registry
.keys()
.map(|name| Value::Str(name.clone()))
.collect();
Ok(Value::Array(agents))
}
// ===================== NANDA (Consensus) Protocol =====================
// A local tally, not a distributed consensus. A proposal names its electorate
// (`electorate`, an Int 1..=1024, default 1) and each vote is one ballot cast
// against it. Nothing identifies the voter, so the electorate is the caller's
// assertion and one caller can cast every ballot. Quorum is half the
// electorate: a proposal commits only when that many ballots are in and the
// approval ratio is at least its threshold. A decided proposal takes no
// further ballots. The store holds at most NANDA_MAX_PROPOSALS and evicts the
// oldest decided proposal when a new one needs the room.
const NANDA_MAX_PROPOSALS: usize = 256;
const NANDA_MAX_ELECTORATE: i64 = 1024;
const NANDA_MAX_DATA_FIELDS: usize = 64;
/// Keys the proposal record owns. A caller's `data` cannot overwrite them.
const NANDA_RESERVED: &[&str] = &[
"id",
"name",
"threshold",
"electorate",
"status",
"votes_cast",
"approvals",
];
struct NandaProposal {
seq: u64,
name: String,
threshold: f64,
electorate: usize,
ballots: Vec<bool>,
status: &'static str,
data: BTreeMap<String, Value>,
}
static NANDA_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
lazy_static::lazy_static! {
static ref NANDA_PROPOSALS: std::sync::RwLock<BTreeMap<String, NandaProposal>> = {
std::sync::RwLock::new(BTreeMap::new())
};
}
fn nanda_uuid() -> String {
use rand::Rng;
let mut rng = rand::thread_rng();
let bytes: [u8; 16] = rng.gen();
format!(
"{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
u16::from_be_bytes([bytes[4], bytes[5]]),
u16::from_be_bytes([bytes[6], bytes[7]]) & 0x0fff,
(u16::from_be_bytes([bytes[8], bytes[9]]) & 0x3fff) | 0x8000,
u64::from_be_bytes([
0, 0, bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]
])
)
}
/// `(cast, approvals)` for a proposal.
fn nanda_tally(p: &NandaProposal) -> (usize, usize) {
let cast = p.ballots.len();
let approvals = p.ballots.iter().filter(|b| **b).count();
(cast, approvals)
}
/// Half the electorate has balloted. An electorate of at least one means a
/// quorum always has at least one ballot behind it, so no ratio divides by zero.
fn nanda_has_quorum(p: &NandaProposal) -> bool {
2 * p.ballots.len() >= p.electorate
}
fn nanda_record(id: &str, p: &NandaProposal) -> BTreeMap<String, Value> {
let (cast, approvals) = nanda_tally(p);
let mut rec = p.data.clone();
rec.insert("id".to_string(), Value::Str(id.to_string()));
rec.insert("name".to_string(), Value::Str(p.name.clone()));
rec.insert("threshold".to_string(), Value::Float(p.threshold));
rec.insert("electorate".to_string(), Value::Int(p.electorate as i64));
rec.insert("status".to_string(), Value::Str(p.status.to_string()));
rec.insert("votes_cast".to_string(), Value::Int(cast as i64));
rec.insert("approvals".to_string(), Value::Int(approvals as i64));
rec
}
/// Refuse pipeline input for a builtin that documents no subject. What was
/// piped in would otherwise be dropped without a word.
fn refuse_piped_subject(builtin: &str, input: &Option<Value>) -> Result<()> {
if input.is_some() {
return Err(crate::safety::bad_arg(
builtin,
"no pipeline input",
"piped input",
));
}
Ok(())
}
/// A required, non-empty String of at most `max` bytes.
fn collab_text_arg(builtin: &str, what: &str, v: Option<&Value>, max: usize) -> Result<String> {
match v {
Some(Value::Str(s)) if !s.trim().is_empty() && s.len() <= max => Ok(s.clone()),
Some(Value::Str(s)) if s.len() > max => Err(crate::safety::bad_arg(
builtin,
&format!("{what}: a String of at most {max} bytes"),
&format!("a String of {} bytes", s.len()),
)),
Some(Value::Str(_)) => Err(crate::safety::bad_arg(
builtin,
&format!("{what}: a non-empty String"),
"an empty String",
)),
other => Err(crate::safety::bad_arg(
builtin,
&format!("{what}: a non-empty String"),
other.map(|v| v.type_name()).unwrap_or("nothing"),
)),
}
}
/// The value of a scalar as it should appear in a refusal: `0.7`, not `Float`.
fn collab_got(v: &Value) -> String {
match v {
Value::Int(n) => n.to_string(),
Value::Float(x) => x.to_string(),
other => other.type_name().to_string(),
}
}
/// nanda_propose(name, data) - Open a proposal in the local tally.
/// `data.threshold` is a Float in (0, 1] (default 0.5); `data.electorate` an
/// Int in 1..=1024 (default 1). Other fields are kept on the record.
fn bi_nanda_propose(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped_subject("nanda_propose", &input)?;
let name = collab_text_arg("nanda_propose", "name", args.first(), 256)?;
let data = match args.get(1) {
Some(Value::Record(r)) => r,
other => {
return Err(crate::safety::bad_arg(
"nanda_propose",
"data: Record",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let threshold = match data.get("threshold") {
None => 0.5,
Some(Value::Float(t)) if *t > 0.0 && *t <= 1.0 => *t,
Some(Value::Int(1)) => 1.0,
Some(other) => {
return Err(crate::safety::bad_arg(
"nanda_propose",
"threshold: a Float in (0, 1]",
&collab_got(other),
))
}
};
let electorate = match data.get("electorate") {
None => 1,
Some(Value::Int(n)) if (1..=NANDA_MAX_ELECTORATE).contains(n) => *n as usize,
Some(other) => {
return Err(crate::safety::bad_arg(
"nanda_propose",
"electorate: an Int in 1..=1024",
&collab_got(other),
))
}
};
if data.len() > NANDA_MAX_DATA_FIELDS {
return Err(crate::safety::budget_exceeded(
"nanda_propose",
format!(
"nanda_propose: data has {} fields; a proposal holds at most {NANDA_MAX_DATA_FIELDS}",
data.len()
),
"send the proposal's detail in fewer fields",
));
}
let mut extra = data.clone();
for key in NANDA_RESERVED {
extra.remove(*key);
}
let id = format!("prop_{}", nanda_uuid());
let mut proposals = NANDA_PROPOSALS
.write()
.map_err(|_| crate::safety::lock_poisoned("nanda_propose", "the NANDA proposal table"))?;
if proposals.len() >= NANDA_MAX_PROPOSALS {
let oldest_decided = proposals
.iter()
.filter(|(_, p)| p.status != "pending")
.min_by_key(|(_, p)| p.seq)
.map(|(k, _)| k.clone());
match oldest_decided {
Some(k) => {
proposals.remove(&k);
}
None => {
return Err(crate::safety::budget_exceeded(
"nanda_propose",
format!(
"nanda_propose: {NANDA_MAX_PROPOSALS} proposals are pending and none is decided"
),
"commit or abort a pending proposal, then propose again",
))
}
}
}
let proposal = NandaProposal {
seq: NANDA_SEQ.fetch_add(1, std::sync::atomic::Ordering::SeqCst),
name,
threshold,
electorate,
ballots: Vec::new(),
status: "pending",
data: extra,
};
let rec = nanda_record(&id, &proposal);
proposals.insert(id, proposal);
Ok(Value::Record(rec))
}
/// nanda_vote(proposal_id, approve) - Cast one ballot on a pending proposal.
fn bi_nanda_vote(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped_subject("nanda_vote", &input)?;
let proposal_id = collab_text_arg("nanda_vote", "proposal_id", args.first(), 128)?;
let approve = match args.get(1) {
Some(Value::Bool(b)) => *b,
other => {
return Err(crate::safety::bad_arg(
"nanda_vote",
"approve: Bool",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let mut proposals = NANDA_PROPOSALS
.write()
.map_err(|_| crate::safety::lock_poisoned("nanda_vote", "the NANDA proposal table"))?;
let p = proposals
.get_mut(&proposal_id)
.ok_or_else(|| crate::safety::not_found("nanda_vote", "proposal", &proposal_id))?;
if p.status != "pending" {
return Err(crate::safety::bad_state(
"nanda_vote",
&format!(
"proposal {proposal_id} is {}; a decided proposal takes no votes",
p.status
),
"nanda_status",
));
}
if p.ballots.len() >= p.electorate {
return Err(crate::safety::budget_exceeded(
"nanda_vote",
format!(
"nanda_vote: all {} ballots of {proposal_id} are cast",
p.electorate
),
"a proposal takes exactly its electorate's ballots; commit it or propose another",
));
}
p.ballots.push(approve);
let mut rec = BTreeMap::new();
rec.insert("voted".to_string(), Value::Bool(true));
rec.insert("proposal_id".to_string(), Value::Str(proposal_id));
rec.insert("approve".to_string(), Value::Bool(approve));
rec.insert("ballot".to_string(), Value::Int(p.ballots.len() as i64));
rec.insert("votes_cast".to_string(), Value::Int(p.ballots.len() as i64));
rec.insert("electorate".to_string(), Value::Int(p.electorate as i64));
Ok(Value::Record(rec))
}
/// nanda_commit(proposal_id) - Commit a pending proposal with quorum and an
/// approval ratio at or above its threshold. Below the threshold it is rejected.
fn bi_nanda_commit(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped_subject("nanda_commit", &input)?;
let proposal_id = collab_text_arg("nanda_commit", "proposal_id", args.first(), 128)?;
let mut proposals = NANDA_PROPOSALS
.write()
.map_err(|_| crate::safety::lock_poisoned("nanda_commit", "the NANDA proposal table"))?;
let p = proposals
.get_mut(&proposal_id)
.ok_or_else(|| crate::safety::not_found("nanda_commit", "proposal", &proposal_id))?;
if p.status != "pending" {
return Err(crate::safety::bad_state(
"nanda_commit",
&format!(
"proposal {proposal_id} is {}; only a pending proposal commits",
p.status
),
"nanda_status",
));
}
if !nanda_has_quorum(p) {
let (cast, _) = nanda_tally(p);
return Err(crate::safety::bad_state(
"nanda_commit",
&format!(
"no quorum: {cast} of {} ballots are cast and at least half are needed",
p.electorate
),
"nanda_vote",
));
}
let (cast, approvals) = nanda_tally(p);
let ratio = approvals as f64 / cast as f64;
if ratio < p.threshold {
p.status = "rejected";
return Err(crate::safety::policy_deny(
"nanda_commit",
format!(
"consensus not reached: approval {:.0}% is below the threshold {:.0}%; {proposal_id} is now rejected",
ratio * 100.0,
p.threshold * 100.0
),
"a rejected proposal takes no further ballots; propose again",
));
}
p.status = "committed";
let mut rec = BTreeMap::new();
rec.insert("committed".to_string(), Value::Bool(true));
rec.insert("proposal_id".to_string(), Value::Str(proposal_id));
rec.insert("approval_ratio".to_string(), Value::Float(ratio));
rec.insert("threshold".to_string(), Value::Float(p.threshold));
rec.insert("votes_cast".to_string(), Value::Int(cast as i64));
rec.insert("electorate".to_string(), Value::Int(p.electorate as i64));
Ok(Value::Record(rec))
}
/// nanda_abort(proposal_id) - Abort a pending proposal.
fn bi_nanda_abort(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped_subject("nanda_abort", &input)?;
let proposal_id = collab_text_arg("nanda_abort", "proposal_id", args.first(), 128)?;
let mut proposals = NANDA_PROPOSALS
.write()
.map_err(|_| crate::safety::lock_poisoned("nanda_abort", "the NANDA proposal table"))?;
let p = proposals
.get_mut(&proposal_id)
.ok_or_else(|| crate::safety::not_found("nanda_abort", "proposal", &proposal_id))?;
if p.status != "pending" {
return Err(crate::safety::bad_state(
"nanda_abort",
&format!(
"proposal {proposal_id} is {}; only a pending proposal aborts",
p.status
),
"nanda_status",
));
}
p.status = "aborted";
let mut rec = BTreeMap::new();
rec.insert("aborted".to_string(), Value::Bool(true));
rec.insert("proposal_id".to_string(), Value::Str(proposal_id));
rec.insert("status".to_string(), Value::Str("aborted".to_string()));
Ok(Value::Record(rec))
}
/// nanda_status(proposal_id?) - One proposal's record, or every proposal's.
fn bi_nanda_status(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped_subject("nanda_status", &input)?;
let proposals = NANDA_PROPOSALS
.read()
.map_err(|_| crate::safety::lock_poisoned("nanda_status", "the NANDA proposal table"))?;
match args.first() {
None => Ok(Value::Array(
proposals
.iter()
.map(|(id, p)| Value::Record(nanda_record(id, p)))
.collect(),
)),
Some(Value::Str(id)) => {
let p = proposals
.get(id)
.ok_or_else(|| crate::safety::not_found("nanda_status", "proposal", id))?;
Ok(Value::Record(nanda_record(id, p)))
}
Some(other) => Err(crate::safety::bad_arg(
"nanda_status",
"proposal_id: String",
other.type_name(),
)),
}
}
/// nanda_consensus(proposal_id) - Whether the tally reaches consensus now: a
/// quorum of ballots, and an approval ratio at or above the threshold.
fn bi_nanda_consensus(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped_subject("nanda_consensus", &input)?;
let proposal_id = collab_text_arg("nanda_consensus", "proposal_id", args.first(), 128)?;
let proposals = NANDA_PROPOSALS
.read()
.map_err(|_| crate::safety::lock_poisoned("nanda_consensus", "the NANDA proposal table"))?;
let p = proposals
.get(&proposal_id)
.ok_or_else(|| crate::safety::not_found("nanda_consensus", "proposal", &proposal_id))?;
let (cast, approvals) = nanda_tally(p);
let ratio = if cast > 0 {
approvals as f64 / cast as f64
} else {
0.0
};
let quorum = nanda_has_quorum(p);
let mut rec = BTreeMap::new();
rec.insert(
"reached".to_string(),
Value::Bool(quorum && cast > 0 && ratio >= p.threshold),
);
rec.insert("quorum_reached".to_string(), Value::Bool(quorum));
rec.insert("approval_ratio".to_string(), Value::Float(ratio));
rec.insert("threshold".to_string(), Value::Float(p.threshold));
rec.insert("total_votes".to_string(), Value::Int(cast as i64));
rec.insert("approvals".to_string(), Value::Int(approvals as i64));
rec.insert("electorate".to_string(), Value::Int(p.electorate as i64));
rec.insert("status".to_string(), Value::Str(p.status.to_string()));
Ok(Value::Record(rec))
}
/// nanda_quorum(proposal_id) - How much of the electorate has balloted.
fn bi_nanda_quorum(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped_subject("nanda_quorum", &input)?;
let proposal_id = collab_text_arg("nanda_quorum", "proposal_id", args.first(), 128)?;
let proposals = NANDA_PROPOSALS
.read()
.map_err(|_| crate::safety::lock_poisoned("nanda_quorum", "the NANDA proposal table"))?;
let p = proposals
.get(&proposal_id)
.ok_or_else(|| crate::safety::not_found("nanda_quorum", "proposal", &proposal_id))?;
let (cast, _) = nanda_tally(p);
let mut rec = BTreeMap::new();
rec.insert(
"quorum_reached".to_string(),
Value::Bool(nanda_has_quorum(p)),
);
rec.insert(
"participation".to_string(),
Value::Float(cast as f64 / p.electorate as f64),
);
rec.insert("votes_cast".to_string(), Value::Int(cast as i64));
rec.insert("electorate".to_string(), Value::Int(p.electorate as i64));
Ok(Value::Record(rec))
}
// ============== AI CODING ASSISTANT BUILTINS ==============
// Git module implementations
//
// Every git builtin refuses pipeline input and surplus arguments (the
// declarations in src/signature.rs enforce the arity and types), runs git
// with an argv vector and no shell, and turns a failed git into a coded
// error: E_BAD_STATE outside a repository, E_TOOL_FAILED with the tail of
// what git printed otherwise. None answers a failure with false, "" or [].
/// `git`, with the repository's fsmonitor hook switched off. `core.fsmonitor`
/// names a program that `git status` and `git diff` run, so a repository's
/// own config could otherwise make a ReadLocal builtin execute code. It is a
/// speed-up only; git answers the same without it.
fn git_command() -> std::process::Command {
let mut cmd = crate::safety::child_command("git");
cmd.args(["-c", "core.fsmonitor=false"]);
cmd
}
/// Refuse pipeline input: a git builtin acts on the repository, and what was
/// piped in would otherwise be dropped without a word.
fn git_refuse_input(builtin: &str, input: &Option<Value>) -> Result<()> {
if input.is_some() {
return Err(crate::safety::bad_arg(
builtin,
"no pipeline input",
"piped input",
));
}
Ok(())
}
/// The last 4 KiB of what git printed when it failed: stderr, or stdout when
/// stderr is empty (`git commit` says "nothing to commit" on stdout alone).
fn git_failure_tail(output: &std::process::Output) -> String {
const TAIL: usize = 4096;
let raw = if output.stderr.iter().all(|b| b.is_ascii_whitespace()) {
&output.stdout
} else {
&output.stderr
};
let text = String::from_utf8_lossy(raw);
if text.len() <= TAIL {
return text.into_owned();
}
let mut start = text.len() - TAIL;
while !text.is_char_boundary(start) {
start += 1;
}
format!("...{}", &text[start..])
}
/// `git` exited non-zero. Every git builtin read stdout and ignored the
/// status, so outside a repository -- where git prints only to stderr --
/// `git_status()` was `[]`, `git_branch()` was `""` and `git_log()` was `[]`,
/// all at exit 0: "clean", "detached" and "no history", none of them true.
fn git_succeeded(builtin: &str, output: &std::process::Output, dir: &str) -> Result<()> {
if output.status.success() {
return Ok(());
}
// Ask git rather than read its stderr: outside a repository `git status`
// says "not a git repository", `git diff` says "Not a git repository" and
// prints usage, `git diff --staged` says only "unknown option", and all
// of it is translated under a non-English locale.
let mut cmd = git_command();
cmd.args(["rev-parse", "--is-inside-work-tree"]);
cmd.current_dir(dir);
let in_repo = bounded_cmd_output("git_succeeded", "git", cmd, TOOL_DEADLINE_SECS)
.is_ok_and(|o| o.status.success());
if !in_repo {
return Err(crate::safety::bad_state(
builtin,
"the working directory is not inside a git repository",
"cd into a repository (or pass its path where the builtin takes one)",
));
}
Err(crate::safety::tool_failed(
builtin,
"git",
output.status.code(),
&git_failure_tail(output),
))
}
/// The directory a read-only git builtin runs in. It is the child's working
/// directory rather than an argument, and spawning into a missing directory
/// fails the same way a missing git does, so it is named here first.
fn git_dir_arg(builtin: &str, path: &str) -> Result<String> {
if std::path::Path::new(path).is_dir() {
Ok(path.to_string())
} else {
Err(crate::safety::not_found(builtin, "directory", path))
}
}
/// The first argument as a ref, branch or commit name. Empty and NUL-bearing
/// values are refused before they reach argv. The caller refuses a leading
/// '-' at its own spawn site, where `reject_option_like` must be visible.
fn git_ref_arg(builtin: &str, args: &[Value], what: &str) -> Result<String> {
let value = match args.first() {
Some(v) => expect_string(builtin, v)?.to_string(),
None => return Err(crate::safety::arg_err(format!("{builtin} requires {what}"))),
};
if value.trim().is_empty() || value.contains('\0') {
return Err(crate::safety::bad_arg(builtin, what, &format!("{value:?}")));
}
Ok(value)
}
fn bi_git_status(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("git_status", &input)?;
let path = match args.first() {
Some(v) => expect_string("git_status", v)?.to_string(),
None => ".".to_string(),
};
let path = git_dir_arg("git_status", &path)?;
let mut cmd = git_command();
cmd.args(["status", "--porcelain", "-b"]);
cmd.current_dir(&path);
let output = bounded_cmd_output("git_status", "git", cmd, TOOL_DEADLINE_SECS)?;
git_succeeded("git_status", &output, &path)?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let lines: Vec<Value> = stdout.lines().map(|l| Value::Str(l.to_string())).collect();
Ok(Value::Array(lines))
}
fn bi_git_diff(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("git_diff", &input)?;
let path = match args.first() {
Some(v) => expect_string("git_diff", v)?.to_string(),
None => ".".to_string(),
};
let path = git_dir_arg("git_diff", &path)?;
let mut cmd = git_command();
cmd.args(["diff", "--no-ext-diff", "--no-textconv"]);
cmd.current_dir(&path);
let output = bounded_cmd_output("git_diff", "git", cmd, TOOL_DEADLINE_SECS)?;
git_succeeded("git_diff", &output, &path)?;
Ok(Value::Str(
String::from_utf8_lossy(&output.stdout).to_string(),
))
}
fn bi_git_diff_staged(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("git_diff_staged", &input)?;
let path = match args.first() {
Some(v) => expect_string("git_diff_staged", v)?.to_string(),
None => ".".to_string(),
};
let path = git_dir_arg("git_diff_staged", &path)?;
let mut cmd = git_command();
cmd.args(["diff", "--no-ext-diff", "--no-textconv", "--staged"]);
cmd.current_dir(&path);
let output = bounded_cmd_output("git_diff_staged", "git", cmd, TOOL_DEADLINE_SECS)?;
git_succeeded("git_diff_staged", &output, &path)?;
Ok(Value::Str(
String::from_utf8_lossy(&output.stdout).to_string(),
))
}
fn bi_git_log(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("git_log", &input)?;
let count = match args.first() {
Some(v) => expect_int("git_log", v)?,
None => 10,
};
// The declaration bounds it to 1..=10000. It is interpolated into a
// `-N` flag, so a negative count (`--5`) must never reach git.
if !(1..=10_000).contains(&count) {
return Err(crate::safety::bad_arg(
"git_log",
"count: Int in 1..=10000",
&format!("{count}"),
));
}
let mut cmd = git_command();
cmd.args(["log", &format!("-{}", count), "--oneline"]);
let output = bounded_cmd_output("git_log", "git", cmd, TOOL_DEADLINE_SECS)?;
git_succeeded("git_log", &output, ".")?;
let lines: Vec<Value> = String::from_utf8_lossy(&output.stdout)
.lines()
.map(|l| Value::Str(l.to_string()))
.collect();
Ok(Value::Array(lines))
}
fn bi_git_blame(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("git_blame", &input)?;
let file = git_ref_arg("git_blame", &args, "a file path")?;
crate::safety::reject_option_like("git_blame", std::slice::from_ref(&file))?;
let mut cmd = git_command();
cmd.args(["blame", "--", &file]);
let output = bounded_cmd_output("git_blame", "git", cmd, TOOL_DEADLINE_SECS)?;
git_succeeded("git_blame", &output, ".")?;
Ok(Value::Str(
String::from_utf8_lossy(&output.stdout).to_string(),
))
}
fn bi_git_branch(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut cmd = git_command();
cmd.args(["branch", "--show-current"]);
let output = bounded_cmd_output("git_branch", "git", cmd, TOOL_DEADLINE_SECS)?;
git_succeeded("git_branch", &output, ".")?;
Ok(Value::Str(
String::from_utf8_lossy(&output.stdout).trim().to_string(),
))
}
fn bi_git_branches(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut cmd = git_command();
cmd.args(["branch", "-a"]);
let output = bounded_cmd_output("git_branches", "git", cmd, TOOL_DEADLINE_SECS)?;
git_succeeded("git_branches", &output, ".")?;
let branches: Vec<Value> = String::from_utf8_lossy(&output.stdout)
.lines()
.map(|l| Value::Str(l.trim().trim_start_matches("* ").to_string()))
.collect();
Ok(Value::Array(branches))
}
fn bi_git_checkout(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("git_checkout", &input)?;
let branch = git_ref_arg("git_checkout", &args, "a branch name")?;
crate::safety::reject_option_like("git_checkout", std::slice::from_ref(&branch))?;
// The `--` makes git resolve the name as a revision only. Without it,
// `git checkout README.md` restores that file from the index and silently
// discards its uncommitted edits. With it, a path is "invalid reference".
let mut cmd = git_command();
cmd.args(["checkout", &branch, "--"]);
let output = bounded_cmd_output("git_checkout", "git", cmd, TOOL_DEADLINE_SECS)?;
git_succeeded("git_checkout", &output, ".")?;
Ok(Value::Bool(true))
}
fn bi_git_commit(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("git_commit", &input)?;
let msg = match args.first() {
Some(v) => expect_string("git_commit", v)?.to_string(),
None => return Err(crate::safety::arg_err("commit message required")),
};
if msg.contains('\0') {
return Err(crate::safety::bad_arg(
"git_commit",
"a message without NUL",
"NUL",
));
}
// The message is the argument of `-m`, which consumes it, so a message
// beginning with '-' is text, not an option.
let mut cmd = git_command();
cmd.args(["commit", "-m", &msg]);
let output = bounded_cmd_output("git_commit", "git", cmd, TOOL_DEADLINE_SECS)?;
git_succeeded("git_commit", &output, ".")?;
Ok(Value::Bool(true))
}
fn bi_git_add(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("git_add", &input)?;
// The declaration requires at least one path and types each one as a
// String, so a non-string is refused rather than dropped from the list.
let files: Vec<String> = args
.iter()
.map(|v| expect_string("git_add", v).map(str::to_string))
.collect::<Result<_>>()?;
if files.is_empty() {
return Err(crate::safety::arg_err("git_add requires at least one path"));
}
crate::safety::reject_option_like("git_add", &files)?;
if let Some(bad) = files.iter().find(|f| f.contains('\0')) {
return Err(crate::safety::bad_arg(
"git_add",
"paths without NUL",
&format!("{bad:?}"),
));
}
let mut cmd = git_command();
cmd.arg("add");
cmd.arg("--");
cmd.args(&files);
let output = bounded_cmd_output("git_add", "git", cmd, TOOL_DEADLINE_SECS)?;
git_succeeded("git_add", &output, ".")?;
Ok(Value::Bool(true))
}
fn bi_git_reset(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("git_reset", &input)?;
let target = match args.first() {
Some(v) => expect_string("git_reset", v)?.to_string(),
None => "--mixed".to_string(),
};
const MODES: [&str; 4] = ["--soft", "--mixed", "--hard", "--keep"];
// A leading '-' is an option and must be one of the four modes; anything
// else is a revision. Either way it is an argv element, never a flag that
// git would take from a caller's string.
if target.starts_with('-') {
if !MODES.contains(&target.as_str()) {
return Err(crate::safety::bad_arg(
"git_reset",
"a mode: --soft, --mixed, --hard or --keep",
&format!("{target:?}"),
));
}
} else if target.trim().is_empty() || target.contains('\0') {
return Err(crate::safety::bad_arg(
"git_reset",
"a mode or a revision",
&format!("{target:?}"),
));
}
// A revision is followed by `--`, so git never reads it as a pathspec
// position it could mistake for an option.
let mut argv = vec!["reset", target.as_str()];
if !target.starts_with('-') {
argv.push("--");
}
let mut cmd = git_command();
cmd.args(&argv);
let output = bounded_cmd_output("git_reset", "git", cmd, TOOL_DEADLINE_SECS)?;
git_succeeded("git_reset", &output, ".")?;
Ok(Value::Bool(true))
}
fn bi_git_stash(_args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("git_stash", &input)?;
let mut cmd = git_command();
cmd.args(["stash"]);
let output = bounded_cmd_output("git_stash", "git", cmd, TOOL_DEADLINE_SECS)?;
git_succeeded("git_stash", &output, ".")?;
Ok(Value::Bool(true))
}
fn bi_git_stash_pop(_args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("git_stash_pop", &input)?;
let mut cmd = git_command();
cmd.args(["stash", "pop"]);
let output = bounded_cmd_output("git_stash_pop", "git", cmd, TOOL_DEADLINE_SECS)?;
git_succeeded("git_stash_pop", &output, ".")?;
Ok(Value::Bool(true))
}
fn bi_git_stash_list(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut cmd = git_command();
cmd.args(["stash", "list"]);
let output = bounded_cmd_output("git_stash_list", "git", cmd, TOOL_DEADLINE_SECS)?;
git_succeeded("git_stash_list", &output, ".")?;
let stashes: Vec<Value> = String::from_utf8_lossy(&output.stdout)
.lines()
.map(|l| Value::Str(l.to_string()))
.collect();
Ok(Value::Array(stashes))
}
fn bi_git_remote(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut cmd = git_command();
cmd.args(["remote", "-v"]);
let output = bounded_cmd_output("git_remote", "git", cmd, TOOL_DEADLINE_SECS)?;
git_succeeded("git_remote", &output, ".")?;
Ok(Value::Str(
String::from_utf8_lossy(&output.stdout).to_string(),
))
}
fn bi_git_fetch(_args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("git_fetch", &input)?;
crate::safety::check_git_remote_egress("git_fetch", false)?;
let mut cmd = git_command();
cmd.args(["fetch"]);
let output = bounded_cmd_output("git_fetch", "git", cmd, NETWORK_DEADLINE_SECS)?;
git_succeeded("git_fetch", &output, ".")?;
Ok(Value::Bool(true))
}
fn bi_git_pull(_args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("git_pull", &input)?;
crate::safety::check_git_remote_egress("git_pull", false)?;
let mut cmd = git_command();
cmd.args(["pull"]);
let output = bounded_cmd_output("git_pull", "git", cmd, NETWORK_DEADLINE_SECS)?;
git_succeeded("git_pull", &output, ".")?;
Ok(Value::Bool(true))
}
fn bi_git_push(_args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("git_push", &input)?;
crate::safety::check_git_remote_egress("git_push", true)?;
let mut cmd = git_command();
cmd.args(["push"]);
let output = bounded_cmd_output("git_push", "git", cmd, NETWORK_DEADLINE_SECS)?;
git_succeeded("git_push", &output, ".")?;
Ok(Value::Bool(true))
}
fn bi_git_merge(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("git_merge", &input)?;
let branch = git_ref_arg("git_merge", &args, "a branch name")?;
crate::safety::reject_option_like("git_merge", std::slice::from_ref(&branch))?;
// --no-edit: never open an editor for the merge message.
let mut cmd = git_command();
cmd.args(["merge", "--no-edit", &branch]);
let output = bounded_cmd_output("git_merge", "git", cmd, TOOL_DEADLINE_SECS)?;
git_succeeded("git_merge", &output, ".")?;
Ok(Value::Bool(true))
}
fn bi_git_rebase(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("git_rebase", &input)?;
let branch = git_ref_arg("git_rebase", &args, "a branch name")?;
crate::safety::reject_option_like("git_rebase", std::slice::from_ref(&branch))?;
let mut cmd = git_command();
cmd.args(["rebase", &branch]);
let output = bounded_cmd_output("git_rebase", "git", cmd, TOOL_DEADLINE_SECS)?;
git_succeeded("git_rebase", &output, ".")?;
Ok(Value::Bool(true))
}
fn bi_git_cherry_pick(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("git_cherry_pick", &input)?;
let commit = git_ref_arg("git_cherry_pick", &args, "a commit")?;
crate::safety::reject_option_like("git_cherry_pick", std::slice::from_ref(&commit))?;
let mut cmd = git_command();
cmd.args(["cherry-pick", &commit]);
let output = bounded_cmd_output("git_cherry_pick", "git", cmd, TOOL_DEADLINE_SECS)?;
git_succeeded("git_cherry_pick", &output, ".")?;
Ok(Value::Bool(true))
}
fn bi_git_tags(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut cmd = git_command();
cmd.args(["tag", "-l"]);
let output = bounded_cmd_output("git_tags", "git", cmd, TOOL_DEADLINE_SECS)?;
git_succeeded("git_tags", &output, ".")?;
let tags: Vec<Value> = String::from_utf8_lossy(&output.stdout)
.lines()
.map(|l| Value::Str(l.to_string()))
.collect();
Ok(Value::Array(tags))
}
fn bi_git_show(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("git_show", &input)?;
let commit = if args.is_empty() {
"HEAD".to_string()
} else {
let value = git_ref_arg("git_show", &args, "a commit")?;
crate::safety::reject_option_like("git_show", std::slice::from_ref(&value))?;
value
};
let mut cmd = git_command();
cmd.args(["show", "--no-ext-diff", "--no-textconv", &commit]);
let output = bounded_cmd_output("git_show", "git", cmd, TOOL_DEADLINE_SECS)?;
git_succeeded("git_show", &output, ".")?;
Ok(Value::Str(
String::from_utf8_lossy(&output.stdout).to_string(),
))
}
fn bi_git_rev_parse(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("git_rev_parse", &input)?;
let rev = if args.is_empty() {
"HEAD".to_string()
} else {
let value = git_ref_arg("git_rev_parse", &args, "a revision")?;
crate::safety::reject_option_like("git_rev_parse", std::slice::from_ref(&value))?;
value
};
let mut cmd = git_command();
cmd.args(["rev-parse", &rev]);
let output = bounded_cmd_output("git_rev_parse", "git", cmd, TOOL_DEADLINE_SECS)?;
git_succeeded("git_rev_parse", &output, ".")?;
Ok(Value::Str(
String::from_utf8_lossy(&output.stdout).trim().to_string(),
))
}
fn bi_git_root(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut cmd = git_command();
cmd.args(["rev-parse", "--show-toplevel"]);
let output = bounded_cmd_output("git_root", "git", cmd, TOOL_DEADLINE_SECS)?;
git_succeeded("git_root", &output, ".")?;
Ok(Value::Str(
String::from_utf8_lossy(&output.stdout).trim().to_string(),
))
}
fn bi_git_ignore(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("git_ignore", &input)?;
let pattern = match args.first() {
Some(v) => expect_string("git_ignore", v)?.to_string(),
None => return Err(crate::safety::arg_err("pattern required")),
};
// One pattern per call: a newline in it wrote several.
if pattern.contains(['\n', '\r']) || pattern.trim().is_empty() {
return Err(crate::safety::bad_arg(
"git_ignore",
"one non-empty pattern without line breaks",
&format!("{pattern:?}"),
));
}
// A .gitignore written outside a repository is a file nobody asked for,
// so the refusal is the same one the other git builtins give.
let mut cmd = git_command();
cmd.args(["rev-parse", "--is-inside-work-tree"]);
let inside = bounded_cmd_output("git_ignore", "git", cmd, TOOL_DEADLINE_SECS)?;
if !(inside.status.success() && inside.stdout.starts_with(b"true")) {
return Err(crate::safety::bad_state(
"git_ignore",
"the working directory is not inside a git repository",
"cd into a repository",
));
}
// The repository is the working directory's, so the file is
// `<cwd>/.gitignore`, named absolutely: the jail judges that file (a
// relative name would be judged in the workspace while the write landed in
// the working directory), and the journal is given the same path.
let cwd =
std::env::current_dir().map_err(|e| crate::safety::fs_error("git_ignore", ".", &e))?;
let path = guard_local_write("git_ignore", &cwd.join(".gitignore").to_string_lossy())?;
// A .gitignore that exists but cannot be read is an error, not an empty
// file: treating it as empty and writing would replace its contents.
let existing = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(crate::safety::fs_error("git_ignore", &path, &e)),
};
// Idempotent: the same pattern was appended again on every call.
if existing.lines().any(|l| l.trim() == pattern.trim()) {
return Ok(Value::Bool(false));
}
let mut text = existing;
if !text.is_empty() && !text.ends_with('\n') {
text.push('\n'); // or the pattern was glued onto the last line
}
text.push_str(pattern.trim());
text.push('\n');
crate::tx::snapshot(&path);
std::fs::write(&path, text).map_err(|e| crate::safety::fs_error("git_ignore", &path, &e))?;
Ok(Value::Bool(true))
}
fn bi_git_clean(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("git_clean", &input)?;
// A non-Bool used to become a dry run without a word. The declaration
// types it, and the match below refuses anything else too.
let dry_run = match args.first() {
Some(Value::Bool(b)) => *b,
Some(other) => {
return Err(crate::safety::bad_arg(
"git_clean",
"dry_run: Bool",
other.type_name(),
))
}
None => true,
};
let flag = if dry_run { "-n" } else { "-f" };
let mut cmd = git_command();
cmd.args(["clean", "-d", flag]);
let output = bounded_cmd_output("git_clean", "git", cmd, TOOL_DEADLINE_SECS)?;
git_succeeded("git_clean", &output, ".")?;
let files: Vec<Value> = String::from_utf8_lossy(&output.stdout)
.lines()
.map(|l| Value::Str(l.to_string()))
.collect();
Ok(Value::Array(files))
}
#[path = "source_navigation.rs"]
mod source_navigation;
// Code module implementations
fn bi_code_parse(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let file = args
.first()
.and_then(|v| v.as_str().ok())
.map(|s| s.to_string())
.ok_or_else(|| crate::safety::arg_err("file path required"))?;
let content = read_text_capped("code_parse", &file)?;
let lines = content.lines().count();
let mut result = BTreeMap::new();
result.insert("path".to_string(), Value::Str(file));
result.insert("lines".to_string(), Value::Int(lines as i64));
result.insert("size".to_string(), Value::Int(content.len() as i64));
Ok(Value::Record(result))
}
fn bi_code_symbols(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let file = args
.first()
.and_then(|v| v.as_str().ok())
.map(|s| s.to_string())
.ok_or_else(|| crate::safety::arg_err("file path required"))?;
let content = read_text_capped("code_symbols", &file)?;
let mut symbols = Vec::new();
for (i, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("fn ")
|| trimmed.starts_with("pub fn ")
|| trimmed.starts_with("struct ")
|| trimmed.starts_with("pub struct ")
|| trimmed.starts_with("enum ")
|| trimmed.starts_with("pub enum ")
|| trimmed.starts_with("impl ")
|| trimmed.starts_with("trait ")
|| trimmed.starts_with("def ")
|| trimmed.starts_with("class ")
|| trimmed.starts_with("function ")
|| trimmed.starts_with("const ")
{
let mut sym = BTreeMap::new();
sym.insert("line".to_string(), Value::Int((i + 1) as i64));
sym.insert("text".to_string(), Value::Str(trimmed.to_string()));
symbols.push(Value::Record(sym));
}
}
Ok(Value::Array(symbols))
}
fn bi_code_outline(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
bi_code_symbols(args, input)
}
fn bi_code_references(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
source_navigation::analyze("code_references", args, input)
}
fn bi_code_definition(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
source_navigation::analyze("code_definition", args, input)
}
fn bi_code_imports(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let file = args
.first()
.and_then(|v| v.as_str().ok())
.map(|s| s.to_string())
.ok_or_else(|| crate::safety::arg_err("file path required"))?;
let content = read_text_capped("code_imports", &file)?;
let imports: Vec<Value> = content
.lines()
.filter(|l| {
l.trim().starts_with("use ")
|| l.trim().starts_with("import ")
|| l.trim().starts_with("from ")
|| l.trim().starts_with("#include")
})
.map(|l| Value::Str(l.trim().to_string()))
.collect();
Ok(Value::Array(imports))
}
fn bi_code_exports(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let file = args
.first()
.and_then(|v| v.as_str().ok())
.map(|s| s.to_string())
.ok_or_else(|| crate::safety::arg_err("file path required"))?;
let content = read_text_capped("code_exports", &file)?;
let exports: Vec<Value> = content
.lines()
.filter(|l| l.trim().starts_with("pub ") || l.trim().starts_with("export "))
.map(|l| Value::Str(l.trim().to_string()))
.collect();
Ok(Value::Array(exports))
}
fn bi_code_dependencies(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
bi_code_imports(args, _input)
}
fn bi_code_callers(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
source_navigation::analyze("code_callers", args, input)
}
fn bi_code_callees(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
source_navigation::analyze("code_callees", args, input)
}
fn bi_code_hover(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
source_navigation::analyze("code_hover", args, input)
}
fn bi_code_signature(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
source_navigation::analyze("code_signature", args, input)
}
fn bi_code_completions(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
source_navigation::analyze("code_completions", args, input)
}
fn bi_code_format(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let file = args
.first()
.and_then(|v| v.as_str().ok())
.map(|s| s.to_string())
.ok_or_else(|| crate::safety::arg_err("file path required"))?;
let ext = std::path::Path::new(&file)
.extension()
.and_then(|s| s.to_str())
.unwrap_or("");
// The path goes to the formatter positionally.
crate::safety::reject_option_like("code_format", std::slice::from_ref(&file))?;
// Each formatter rewrites the file in place, and none was jailed.
let file = guard_local_write("code_format", &file)?;
crate::tx::snapshot(&file);
let (program, cmd) = match ext {
"rs" => {
let mut cmd = crate::safety::child_command("rustfmt");
cmd.arg(&file);
("rustfmt", cmd)
}
"py" => {
let mut cmd = crate::safety::child_command("black");
cmd.arg(&file);
("black", cmd)
}
"js" | "ts" => {
let mut cmd = crate::safety::child_command("prettier");
cmd.args(["--write", &file]);
("prettier", cmd)
}
// An unsupported extension, a missing formatter and a formatter that
// failed were all `false`.
other => {
return Err(crate::safety::bad_arg(
"code_format",
"a .rs, .py, .js or .ts file",
&format!("a .{other} file"),
))
}
};
let output = bounded_cmd_output("code_format", program, cmd, TOOL_DEADLINE_SECS)?;
if !output.status.success() {
return Err(crate::safety::tool_failed(
"code_format",
program,
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
Ok(Value::Bool(true))
}
fn bi_code_language(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let file = args
.first()
.and_then(|v| v.as_str().ok())
.map(|s| s.to_string())
.ok_or_else(|| crate::safety::arg_err("file path required"))?;
let ext = std::path::Path::new(&file)
.extension()
.and_then(|s| s.to_str())
.unwrap_or("");
let lang = match ext {
"rs" => "rust",
"py" => "python",
"js" => "javascript",
"ts" => "typescript",
"go" => "go",
"java" => "java",
"c" | "h" => "c",
"cpp" | "hpp" | "cc" => "cpp",
"rb" => "ruby",
"sh" => "shell",
"md" => "markdown",
"json" => "json",
"toml" => "toml",
"yaml" | "yml" => "yaml",
_ => "unknown",
};
Ok(Value::Str(lang.to_string()))
}
fn bi_code_comments(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let file = args
.first()
.and_then(|v| v.as_str().ok())
.map(|s| s.to_string())
.ok_or_else(|| crate::safety::arg_err("file path required"))?;
let content = read_text_capped("code_comments", &file)?;
let comments: Vec<Value> = content
.lines()
.enumerate()
.filter(|(_, l)| {
let t = l.trim();
t.starts_with("//") || t.starts_with("#") || t.starts_with("/*") || t.starts_with("*")
})
.map(|(i, l)| {
let mut rec = BTreeMap::new();
rec.insert("line".to_string(), Value::Int((i + 1) as i64));
rec.insert("text".to_string(), Value::Str(l.trim().to_string()));
Value::Record(rec)
})
.collect();
Ok(Value::Array(comments))
}
fn bi_code_todos(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let file = args
.first()
.and_then(|v| v.as_str().ok())
.map(|s| s.to_string())
.ok_or_else(|| crate::safety::arg_err("file path required"))?;
let content = read_text_capped("code_todos", &file)?;
let todos: Vec<Value> = content
.lines()
.enumerate()
.filter(|(_, l)| l.to_uppercase().contains("TODO") || l.to_uppercase().contains("FIXME"))
.map(|(i, l)| {
let mut rec = BTreeMap::new();
rec.insert("line".to_string(), Value::Int((i + 1) as i64));
rec.insert("text".to_string(), Value::Str(l.trim().to_string()));
Value::Record(rec)
})
.collect();
Ok(Value::Array(todos))
}
// Project module implementations
fn bi_project_type(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
if std::path::Path::new("Cargo.toml").exists() {
return Ok(Value::Str("rust".to_string()));
}
if std::path::Path::new("package.json").exists() {
return Ok(Value::Str("node".to_string()));
}
if std::path::Path::new("pyproject.toml").exists() || std::path::Path::new("setup.py").exists()
{
return Ok(Value::Str("python".to_string()));
}
if std::path::Path::new("go.mod").exists() {
return Ok(Value::Str("go".to_string()));
}
if std::path::Path::new("pom.xml").exists() {
return Ok(Value::Str("java".to_string()));
}
Ok(Value::Str("unknown".to_string()))
}
fn bi_project_root(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut cmd = git_command();
cmd.args(["rev-parse", "--show-toplevel"]);
// The doc promises no fallback, so a directory that is not a repository
// (or no git at all) is not reported as the root. It used to answer the
// current directory for every failure, which named a subdirectory as the
// project root.
let output = bounded_cmd_output("project_root", "git", cmd, TOOL_DEADLINE_SECS);
let cwd = std::env::current_dir()?.to_string_lossy().to_string();
match output {
Ok(o) if o.status.success() => Ok(Value::Str(
String::from_utf8_lossy(&o.stdout).trim().to_string(),
)),
Ok(o) if String::from_utf8_lossy(&o.stderr).contains("not a git repository") => Err(
crate::safety::not_found("project_root", "project root", &cwd),
),
Ok(o) => Err(crate::safety::tool_failed(
"project_root",
"git rev-parse --show-toplevel",
o.status.code(),
&String::from_utf8_lossy(&o.stderr),
)),
// No git is E_TOOL_MISSING from the spawn, not "no project root".
Err(e) => Err(e),
}
}
/// Read a manifest that may be absent. `None` means the file does not exist.
/// Other read errors are coded errors, not an empty answer.
fn project_manifest_optional(builtin: &str, path: &str) -> Result<Option<String>> {
match std::fs::read_to_string(path) {
Ok(text) => Ok(Some(text)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(crate::safety::fs_error(builtin, path, &e)),
}
}
/// Read a Cargo.toml that must exist. A missing file is a coded not_found.
fn project_cargo_text(builtin: &str) -> Result<String> {
match project_manifest_optional(builtin, "Cargo.toml")? {
Some(text) => Ok(text),
None => Err(crate::safety::not_found(
builtin,
"Cargo.toml",
"Cargo.toml",
)),
}
}
/// The `name = "..."` value in Cargo.toml text. Old code returned "unknown"
/// when the value was absent or malformed.
fn cargo_name_from_text(builtin: &str, text: &str) -> Result<String> {
for line in text.lines() {
if line.starts_with("name = ") {
if let Some(name) = line.split('"').nth(1) {
return Ok(name.to_string());
}
}
}
Err(crate::safety::bad_state(
builtin,
"Cargo.toml has no name = line",
"project_type",
))
}
/// The `version = "..."` value in Cargo.toml text. Old code returned "0.0.0"
/// when the value was absent or malformed.
fn cargo_version_from_text(builtin: &str, text: &str) -> Result<String> {
for line in text.lines() {
if line.starts_with("version = ") {
if let Some(version) = line.split('"').nth(1) {
return Ok(version.to_string());
}
}
}
Err(crate::safety::bad_state(
builtin,
"Cargo.toml has no version = line",
"project_type",
))
}
/// A top-level string field of package.json text, such as `"name"`.
fn package_json_field(content: &str, field: &str) -> Option<String> {
match serde_json::from_str::<serde_json::Value>(content)
.ok()?
.get(field)?
{
serde_json::Value::String(v) => Some(v.clone()),
_ => None,
}
}
/// The keys listed under one Cargo.toml table header (for example
/// `[dependencies]`). A table with no entries gives an empty list, which is a
/// real answer; a missing Cargo.toml is handled by the caller.
fn cargo_section_keys(text: &str, header: &str) -> Vec<Value> {
let mut keys = Vec::new();
let mut in_section = false;
for line in text.lines() {
if line.starts_with(header) {
in_section = true;
continue;
}
if line.starts_with("[") {
in_section = false;
}
if in_section && line.contains("=") {
keys.push(Value::Str(
line.split('=').next().unwrap_or("").trim().to_string(),
));
}
}
keys
}
fn bi_project_name(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
const B: &str = "project_name";
let cargo = project_manifest_optional(B, "Cargo.toml")?;
if let Some(text) = &cargo {
if let Ok(name) = cargo_name_from_text(B, text) {
return Ok(Value::Str(name));
}
}
let pkg = project_manifest_optional(B, "package.json")?;
if let Some(name) = pkg.as_deref().and_then(|t| package_json_field(t, "name")) {
return Ok(Value::Str(name));
}
if cargo.is_none() && pkg.is_none() {
return Err(crate::safety::not_found(B, "Cargo.toml", "Cargo.toml"));
}
Err(crate::safety::bad_state(
B,
"no name in Cargo.toml or package.json",
"project_type",
))
}
fn bi_project_version(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
const B: &str = "project_version";
// A Node project has no Cargo.toml; read its package.json as
// project_name does, rather than answer "0.0.0" or refuse it.
if let Some(text) = project_manifest_optional(B, "Cargo.toml")? {
return Ok(Value::Str(cargo_version_from_text(B, &text)?));
}
match project_manifest_optional(B, "package.json")? {
Some(text) => package_json_field(&text, "version")
.map(Value::Str)
.ok_or_else(|| {
crate::safety::bad_state(B, "package.json has no \"version\"", "project_type")
}),
None => Err(crate::safety::not_found(
B,
"Cargo.toml or package.json",
".",
)),
}
}
fn bi_project_dependencies(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let text = project_cargo_text("project_dependencies")?;
Ok(Value::Array(cargo_section_keys(&text, "[dependencies]")))
}
fn bi_project_dev_dependencies(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let text = project_cargo_text("project_dev_dependencies")?;
Ok(Value::Array(cargo_section_keys(
&text,
"[dev-dependencies]",
)))
}
#[cfg(test)]
mod quiet_misc_tests {
use super::*;
#[test]
fn cargo_version_is_read_from_package_section() {
let text = "[package]\nname = \"ae\"\nversion = \"1.2.3\"\n";
assert_eq!(
cargo_version_from_text("project_version", text).unwrap(),
"1.2.3"
);
}
#[test]
fn cargo_version_missing_is_an_error_not_0_0_0() {
let text = "[package]\nname = \"ae\"\n";
assert!(cargo_version_from_text("project_version", text).is_err());
}
#[test]
fn cargo_name_is_read_from_package_section() {
let text = "[package]\nname = \"ae\"\nversion = \"1.2.3\"\n";
assert_eq!(cargo_name_from_text("project_name", text).unwrap(), "ae");
}
#[test]
fn cargo_name_missing_is_an_error_not_unknown() {
let text = "[package]\nversion = \"1.2.3\"\n";
assert!(cargo_name_from_text("project_name", text).is_err());
}
#[test]
fn cargo_section_keys_empty_table_is_a_real_empty_list() {
let text = "[package]\nname = \"ae\"\n\n[dependencies]\n\n[dev-dependencies]\nx = \"1\"\n";
assert!(cargo_section_keys(text, "[dependencies]").is_empty());
assert_eq!(cargo_section_keys(text, "[dev-dependencies]").len(), 1);
}
}
#[path = "project_manifest.rs"]
mod project_manifest;
fn bi_project_scripts(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
project_manifest::guard_and_discover(args, input)
}
fn bi_project_structure(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut files = Vec::new();
// An unreadable directory used to be skipped, so its entries silently
// vanished from a listing that looked complete. It is an error now.
fn walk(dir: &std::path::Path, files: &mut Vec<Value>, depth: usize) -> Result<()> {
if depth > 3 {
return Ok(());
}
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
// Removed since its parent listed it: nothing left to report.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(crate::safety::fs_error("project_structure", dir, &e)),
};
for entry in entries {
let entry = entry.map_err(|e| crate::safety::fs_error("project_structure", dir, &e))?;
let path = entry.path();
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if name.starts_with(".") || name == "target" || name == "node_modules" {
continue;
}
files.push(Value::Str(path.to_string_lossy().to_string()));
if entry_is_dir("project_structure", &path)? {
walk(&path, files, depth + 1)?;
}
}
Ok(())
}
walk(std::path::Path::new("."), &mut files, 0)?;
Ok(Value::Array(files))
}
fn bi_project_entry_points(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut entries = Vec::new();
if std::path::Path::new("src/main.rs").exists() {
entries.push(Value::Str("src/main.rs".to_string()));
}
if std::path::Path::new("src/lib.rs").exists() {
entries.push(Value::Str("src/lib.rs".to_string()));
}
if std::path::Path::new("index.js").exists() {
entries.push(Value::Str("index.js".to_string()));
}
if std::path::Path::new("main.py").exists() {
entries.push(Value::Str("main.py".to_string()));
}
Ok(Value::Array(entries))
}
fn bi_project_test_files(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut tests = Vec::new();
// A tests/ directory that cannot be listed used to read as no tests.
let dir = std::path::Path::new("tests");
if entry_is_dir("project_test_files", dir)? {
let entries = std::fs::read_dir(dir)
.map_err(|e| crate::safety::fs_error("project_test_files", dir, &e))?;
for entry in entries {
let entry =
entry.map_err(|e| crate::safety::fs_error("project_test_files", dir, &e))?;
tests.push(Value::Str(entry.path().to_string_lossy().to_string()));
}
}
Ok(Value::Array(tests))
}
fn bi_project_config_files(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let configs = [
"Cargo.toml",
"package.json",
"pyproject.toml",
"tsconfig.json",
".eslintrc.json",
"rustfmt.toml",
];
let found: Vec<Value> = configs
.iter()
.filter(|c| std::path::Path::new(c).exists())
.map(|c| Value::Str(c.to_string()))
.collect();
Ok(Value::Array(found))
}
// Each reader used to turn any read error (permission, not UTF-8, I/O) into
// Null, the same answer as "no such file". Only a missing file is Null now.
fn bi_project_readme(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
first_project_text(
"project_readme",
&["README.md", "README.txt", "README", "readme.md"],
)
}
fn bi_project_license(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
first_project_text("project_license", &["LICENSE", "LICENSE.md", "LICENSE.txt"])
}
fn bi_project_gitignore(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
first_project_text("project_gitignore", &[".gitignore"])
}
fn bi_project_languages(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut langs = std::collections::HashSet::new();
fn check_ext(path: &std::path::Path, langs: &mut std::collections::HashSet<String>) {
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
let lang = match ext {
"rs" => "Rust",
"py" => "Python",
"js" => "JavaScript",
"ts" => "TypeScript",
"go" => "Go",
"java" => "Java",
"rb" => "Ruby",
"c" | "h" => "C",
"cpp" | "hpp" => "C++",
"sh" => "Shell",
_ => return,
};
langs.insert(lang.to_string());
}
}
// An unreadable directory used to be skipped, so its languages silently
// dropped out of the answer. It is an error now.
fn walk(
dir: &std::path::Path,
langs: &mut std::collections::HashSet<String>,
depth: usize,
) -> Result<()> {
if depth > 3 {
return Ok(());
}
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
// Removed since its parent listed it: nothing left to report.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(crate::safety::fs_error("project_languages", dir, &e)),
};
for entry in entries {
let entry = entry.map_err(|e| crate::safety::fs_error("project_languages", dir, &e))?;
let path = entry.path();
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if name.starts_with(".") || name == "target" || name == "node_modules" {
continue;
}
if entry_is_dir("project_languages", &path)? {
walk(&path, langs, depth + 1)?;
} else if entry_is_file("project_languages", &path)? {
check_ext(&path, langs);
}
}
Ok(())
}
walk(std::path::Path::new("."), &mut langs, 0)?;
Ok(Value::Array(langs.into_iter().map(Value::Str).collect()))
}
/// Source lines under the working directory.
///
/// This ran `wc -l src/*.rs`. There is no shell between `Command::new` and
/// `wc`, so nothing expanded the glob: `wc` was handed a file literally named
/// `src/*.rs`, failed, and the function fell through to `Ok(Value::Int(0))`.
/// It reported 0 lines for every project on every platform, and reported it as
/// a fact rather than a failure. Counting in Rust removes both the glob and
/// the subprocess.
fn bi_project_loc(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
Ok(Value::Int(project_loc_in(std::path::Path::new("."))?))
}
/// The count behind `project_loc`, over `root`. Split out so that the tests
/// can point it at a temporary tree; the builtin itself always counts `.`.
fn project_loc_in(root: &std::path::Path) -> Result<i64> {
const SOURCE_EXT: &[&str] = &[
"rs", "py", "js", "ts", "tsx", "jsx", "go", "java", "c", "h", "cc", "cpp", "hpp", "cs",
"rb", "sh", "ish", "lua", "kt", "swift", "php", "scala",
];
// Directories that are build output or vendored code: counting them
// measures someone else's project, and on a Rust tree `target/` alone
// dwarfs the source.
const SKIP: &[&str] = &[
"target",
"node_modules",
".git",
"dist",
"build",
"vendor",
".venv",
"__pycache__",
];
/// Lines as `str::lines` counts them: one per newline, plus a final line
/// that has no newline after it.
fn count_lines(bytes: &[u8]) -> i64 {
let newlines = bytes.iter().filter(|b| **b == b'\n').count();
let unterminated = !bytes.is_empty() && bytes.last() != Some(&b'\n');
(newlines + usize::from(unterminated)) as i64
}
fn count(dir: &std::path::Path, total: &mut i64, budget: &mut u32) -> Result<()> {
// A budget rather than unbounded recursion: this is called from an
// agent loop, and a symlinked or pathological tree must not cost the
// whole turn. Exhausting it is reported as E_BUDGET_EXCEEDED, never as
// a number -- a partial count returned as an Int is indistinguishable
// from a small project, which is the defect this replaced.
if *budget == 0 {
return Ok(());
}
// An unreadable directory used to be skipped, so its sources were
// missing from the total and the total was returned as the count.
let entries =
std::fs::read_dir(dir).map_err(|e| crate::safety::fs_error("project_loc", dir, &e))?;
for entry in entries {
let entry = entry.map_err(|e| crate::safety::fs_error("project_loc", dir, &e))?;
*budget = budget.saturating_sub(1);
if *budget == 0 {
return Ok(());
}
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if entry_is_dir("project_loc", &path)? {
if !SKIP.contains(&name.as_ref()) && !name.starts_with('.') {
count(&path, total, budget)?;
}
} else if path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| SOURCE_EXT.contains(&e))
{
// Bytes, not `read_to_string`: that refused any non-UTF-8 file
// (Latin-1 source, say), and the file counted as 0 lines.
let bytes = match read_bytes_capped("project_loc", &path) {
Ok(bytes) => bytes,
// Removed since it was listed, or a dangling symlink.
Err(e) if capped_read_not_found(&e) => continue,
Err(e) => return Err(e),
};
*total += count_lines(&bytes);
}
}
Ok(())
}
let mut total = 0i64;
const BUDGET: u32 = 200_000;
let mut budget = BUDGET;
count(root, &mut total, &mut budget)?;
if budget == 0 {
return Err(crate::safety::budget_exceeded(
"project_loc",
format!("stopped after {BUDGET} directory entries; the count is partial"),
"run it against a subdirectory, or exclude generated trees",
));
}
Ok(total)
}
fn bi_project_size(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut total: u64 = 0;
// An unreadable directory or entry used to be skipped, and a failed stat
// counted as 0 bytes: the size was the sum of what happened to be readable.
fn walk(dir: &std::path::Path, total: &mut u64, depth: usize) -> Result<()> {
if depth > 5 {
return Ok(());
}
let entries =
std::fs::read_dir(dir).map_err(|e| crate::safety::fs_error("project_size", dir, &e))?;
for entry in entries {
let path = entry
.map_err(|e| crate::safety::fs_error("project_size", dir, &e))?
.path();
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if name.starts_with(".") || name == "target" || name == "node_modules" {
continue;
}
if entry_is_dir("project_size", &path)? {
walk(&path, total, depth + 1)?;
} else {
match std::fs::metadata(&path) {
Ok(meta) if meta.is_file() => *total += meta.len(),
Ok(_) => {}
// Removed since it was listed, or a dangling symlink.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(crate::safety::fs_error("project_size", &path, &e)),
}
}
}
Ok(())
}
walk(std::path::Path::new("."), &mut total, 0)?;
Ok(Value::Int(total as i64))
}
// ---------------------------------------------------------------------------
// Recursive search helpers for the `search_*` family
// ---------------------------------------------------------------------------
/// Directories a code search must never descend into.
///
/// `search_fixmes` ran `grep -rn FIXME .` with no exclusions. From a project
/// root that walks `node_modules`, `.git` and build output: slow, and full of
/// third-party noise -- `search_todos` returned a TODO from
/// `.git/hooks/sendemail-validate.sample`. Under WSL2, two such walks running
/// at once over `/mnt/c` stalled in 9P (`wchan = p9_client_rpc`, state D)
/// inside `node_modules/lucide-react/dist/esm/icons`, and because
/// `Command::output()` waits forever, the builtin never returned.
const SEARCH_SKIP_DIRS: &[&str] = &[
"node_modules",
".git",
"target",
"dist",
"build",
"vendor",
".venv",
"__pycache__",
];
/// A search that has not finished in this long is not going to be useful to
/// an agent, and a bounded refusal is worth more than an unbounded wait.
const SEARCH_TIMEOUT_SECS: u64 = 30;
/// Everything a search needs to know about one directory entry.
struct SearchEntry {
/// `./relative/path`, the form `find .` and `grep -r .` printed.
shown: String,
path: std::path::PathBuf,
is_file: bool,
meta: std::fs::Metadata,
}
/// Walk `.` in sorted order, skipping [`SEARCH_SKIP_DIRS`] and symlinks, and
/// hand each entry to `visit`.
///
/// This replaces shelling out to `find` and `grep`. The subprocesses were the
/// problem three times over: on Windows `find` is System32\find.exe, `grep` is
/// only present when Git's tools happen to be on PATH, and on WSL two
/// concurrent walks over /mnt/c stalled in 9P until the builtin never returned.
/// A walk in-process is the same everywhere, and it is sorted, where `find`
/// and `grep` printed in whatever order the directory happened to list --
/// which made two identical searches differ.
///
/// Symlinks are skipped, matching what the subprocesses did: `find -type f`
/// excludes them and `grep -r` follows only those named on the command line.
fn search_walk(builtin: &str, visit: &mut dyn FnMut(&SearchEntry) -> Result<()>) -> Result<()> {
const BUDGET: u32 = 200_000;
let started = std::time::Instant::now();
let mut budget = BUDGET;
let mut stack = vec![(std::path::PathBuf::from("."), String::from("."))];
while let Some((dir, shown_dir)) = stack.pop() {
// A directory that cannot be listed used to be skipped with `continue`,
// and an entry that could not be read was dropped by `flatten`. The
// search then reported the matches it had found as if they were all.
let rd = std::fs::read_dir(&dir).map_err(|e| crate::safety::fs_error(builtin, &dir, &e))?;
let mut entries = Vec::new();
for entry in rd {
entries.push(entry.map_err(|e| crate::safety::fs_error(builtin, &dir, &e))?);
}
entries.sort_by_key(|e| e.file_name());
let mut subdirs = Vec::new();
for entry in entries {
// A search that has not finished in this long is not going to be
// useful to an agent. Refusing is worth more than an unbounded wait,
// and a partial list returned as the whole answer would be the
// silent wrong answer this family used to give.
if budget == 0 || started.elapsed().as_secs() >= SEARCH_TIMEOUT_SECS {
return Err(crate::safety::budget_exceeded(
builtin,
format!(
"stopped after {} entries and {}s; the result would be partial, \
so nothing was returned",
BUDGET - budget,
started.elapsed().as_secs()
),
"search a narrower directory",
));
}
budget -= 1;
let meta = match std::fs::symlink_metadata(entry.path()) {
Ok(meta) => meta,
// Removed since it was listed: nothing left to report.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(crate::safety::fs_error(builtin, entry.path(), &e)),
};
if meta.file_type().is_symlink() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
let shown = format!("{shown_dir}/{name}");
if meta.is_dir() && SEARCH_SKIP_DIRS.contains(&name.as_str()) {
continue;
}
let item = SearchEntry {
shown: shown.clone(),
path: entry.path(),
is_file: meta.is_file(),
meta,
};
visit(&item)?;
if item.meta.is_dir() {
subdirs.push((item.path, shown));
}
}
// Reverse so that popping visits subdirectories in sorted order.
stack.extend(subdirs.into_iter().rev());
}
Ok(())
}
/// Shell-style glob on a file name: `*`, `?` and `[...]` (with `!` or `^`
/// negation and `a-z` ranges) -- what `find -name` accepted.
fn shell_glob(pat: &[char], name: &[char]) -> bool {
match pat.first() {
None => name.is_empty(),
Some('*') => (0..=name.len()).any(|i| shell_glob(&pat[1..], &name[i..])),
Some('?') => !name.is_empty() && shell_glob(&pat[1..], &name[1..]),
Some('[') => {
let Some(close) = pat.iter().skip(2).position(|&c| c == ']').map(|p| p + 2) else {
// No closing bracket: `[` is a literal, as in the shell.
return name.first() == Some(&'[') && shell_glob(&pat[1..], &name[1..]);
};
let Some(&c) = name.first() else {
return false;
};
let class = &pat[1..close];
let (negate, class) = match class.first() {
Some('!') | Some('^') => (true, &class[1..]),
_ => (false, class),
};
let mut hit = false;
let mut i = 0;
while i < class.len() {
if i + 2 < class.len() && class[i + 1] == '-' {
hit |= class[i] <= c && c <= class[i + 2];
i += 3;
} else {
hit |= class[i] == c;
i += 1;
}
}
hit != negate && shell_glob(&pat[close + 1..], &name[1..])
}
Some(&p) => name.first() == Some(&p) && shell_glob(&pat[1..], &name[1..]),
}
}
/// `find .`'s `-size` test: `[+|-]N[c|k|M|G|b]`, where a size is rounded UP
/// to whole units before comparing, and a bare number means 512-byte blocks.
/// `+1M` is "more than one MiB", `-10k` "under ten KiB".
fn find_size_matches(builtin: &str, spec: &str, bytes: u64) -> Result<bool> {
let (cmp, rest) = match spec.chars().next() {
Some('+') => ('+', &spec[1..]),
Some('-') => ('-', &spec[1..]),
_ => ('=', spec),
};
let (digits, unit) = match rest.chars().last() {
Some(u) if u.is_ascii_alphabetic() => (&rest[..rest.len() - 1], u),
_ => (rest, 'b'),
};
let unit_bytes: u64 = match unit {
'c' => 1,
'k' => 1024,
'M' => 1024 * 1024,
'G' => 1024 * 1024 * 1024,
'b' => 512,
_ => {
return Err(crate::safety::bad_arg(
builtin,
"a find-style size such as +1M, -10k or 100c",
spec,
))
}
};
let n: u64 = digits.parse().map_err(|_| {
crate::safety::bad_arg(builtin, "a find-style size such as +1M, -10k or 100c", spec)
})?;
let units = bytes.div_ceil(unit_bytes);
Ok(match cmp {
'+' => units > n,
'-' => units < n,
_ => units == n,
})
}
/// Search file contents for `pattern` under `.`, as `grep -rnI` did: lines
/// printed as `./path:line:text`, binary files skipped.
///
/// The pattern is a Rust regular expression. `grep` read a POSIX basic
/// regular expression, and the two agree on the literals every caller in this
/// file passes (`TODO`, `FIXME`). They differ on grouping -- `foo(` is a
/// literal `(` to grep and an unclosed group here -- so a pattern that does not
/// compile is refused with the reason, never searched for as something else.
fn search_grep(builtin: &str, pattern: &str) -> Result<Vec<String>> {
let re = regex::Regex::new(pattern).map_err(|e| {
crate::safety::bad_arg(
builtin,
"a regular expression (Rust regex syntax)",
&e.to_string(),
)
})?;
let mut out = Vec::new();
search_walk(builtin, &mut |e| {
if !e.is_file {
return Ok(());
}
let Some(text) = search_read_text(builtin, &e.path, &e.shown)? else {
return Ok(());
};
for (i, line) in text.lines().enumerate() {
if re.is_match(line) {
if out.len() >= SEARCH_MAX_RESULTS {
return Err(search_budget(builtin));
}
out.push(format!("{}:{}:{}", e.shown, i + 1, line));
}
}
Ok(())
})?;
Ok(out)
}
/// Find files under `.` by the subset of `find` predicates the `search_*`
/// builtins use: `-type f`, `-name GLOB`, `-mtime -N` and `-size SPEC`.
///
/// Without `-type f` a matching directory is listed too, as `find` did.
fn search_find(builtin: &str, predicates: &[&str]) -> Result<Vec<String>> {
let mut files_only = false;
let mut name: Option<Vec<char>> = None;
let mut newer_than_days: Option<u64> = None;
let mut size: Option<String> = None;
let mut it = predicates.iter();
while let Some(&p) = it.next() {
match (p, it.next()) {
("-type", Some(&"f")) => files_only = true,
("-name", Some(&g)) => name = Some(g.chars().collect()),
("-mtime", Some(&d)) if d.starts_with('-') => {
newer_than_days =
Some(d[1..].parse().map_err(|_| {
crate::safety::bad_arg(builtin, "a whole number of days", d)
})?)
}
("-size", Some(&spec)) => size = Some(spec.to_string()),
(other, _) => {
// Only reachable by a change to this file, never by a caller:
// every predicate list is a literal above.
return Err(crate::safety::arg_err(format!(
"{builtin}: unsupported search predicate {other}"
)));
}
}
}
let now = std::time::SystemTime::now();
let mut out = Vec::new();
search_walk(builtin, &mut |e| {
if files_only && !e.is_file {
return Ok(());
}
if let Some(g) = &name {
let base: Vec<char> = e
.path
.file_name()
.map(|n| n.to_string_lossy().chars().collect())
.unwrap_or_default();
if !shell_glob(g, &base) {
return Ok(());
}
}
if let Some(days) = newer_than_days {
let age = e
.meta
.modified()
.ok()
.and_then(|m| now.duration_since(m).ok())
.map(|d| d.as_secs())
.unwrap_or(u64::MAX);
if age >= days.saturating_mul(86_400) {
return Ok(());
}
}
if let Some(spec) = &size {
if !find_size_matches(builtin, spec, e.meta.len())? {
return Ok(());
}
}
out.push(e.shown.clone());
Ok(())
})?;
Ok(out)
}
// Search module implementations
fn bi_search_code(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
search_pattern_builtin("search_code", args, input)
}
fn bi_search_grep(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
search_pattern_builtin("search_grep", args, input)
}
fn bi_search_regex(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
search_pattern_builtin("search_regex", args, input)
}
/// The content search behind `search_code`, `search_grep` and `search_regex`:
/// one regular expression, under the workspace, capped at [`SEARCH_MAX_RESULTS`].
fn bi_search_symbols(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let pattern = args
.first()
.and_then(|v| v.as_str().ok())
.map(|s| s.to_string())
.ok_or_else(|| crate::safety::arg_err("pattern required"))?;
// This ran `grep -rn "fn X|struct X|class X|def X" .`. Without `-E`, grep
// reads a BASIC regular expression, in which `|` is a literal pipe -- so it
// searched for that whole string, matched nothing, and returned [] for
// every symbol in every project. `search_grep` takes a Rust regex, where
// `|` alternates as intended; the symbol itself is escaped so that `a.b`
// means `a.b`.
let found = search_grep(
"search_symbols",
&format!(r"(?:fn|struct|class|def)\s+{}\b", regex::escape(&pattern)),
)?;
let matches: Vec<Value> = found
.iter()
.take(50)
.map(|l| Value::Str(l.to_string()))
.collect();
Ok(Value::Array(matches))
}
fn bi_search_files(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let pattern = args
.first()
.and_then(|v| v.as_str().ok())
.map(|s| s.to_string())
.ok_or_else(|| crate::safety::arg_err("pattern required"))?;
let found = search_find("search_files", &["-name", &pattern])?;
let files: Vec<Value> = found.iter().map(|l| Value::Str(l.to_string())).collect();
Ok(Value::Array(files))
}
fn bi_search_recent(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let found = search_find("search_recent", &["-type", "f", "-mtime", "-1"])?;
let files: Vec<Value> = found
.iter()
.take(50)
.map(|l| Value::Str(l.to_string()))
.collect();
Ok(Value::Array(files))
}
fn bi_search_modified(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let days = args.first().and_then(|v| v.as_int().ok()).unwrap_or(7);
let found = search_find(
"search_modified",
&["-type", "f", "-mtime", &format!("-{}", days)],
)?;
let files: Vec<Value> = found
.iter()
.take(100)
.map(|l| Value::Str(l.to_string()))
.collect();
Ok(Value::Array(files))
}
fn bi_search_by_type(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let ext = args
.first()
.and_then(|v| v.as_str().ok())
.map(|s| s.to_string())
.ok_or_else(|| crate::safety::arg_err("extension required"))?;
let found = search_find("search_by_type", &["-name", &format!("*.{}", ext)])?;
let files: Vec<Value> = found.iter().map(|l| Value::Str(l.to_string())).collect();
Ok(Value::Array(files))
}
fn bi_search_by_size(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "search_by_size";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 1)?;
let size = match args.first() {
None => "+1M".to_string(),
Some(Value::Str(s)) => s.clone(),
Some(other) => {
return Err(crate::safety::bad_arg(
B,
"a find-style size such as +1M, -10k or 100c",
other.type_name(),
))
}
};
// Checked before the walk: an empty tree would otherwise answer [] for a
// size that was never understood.
find_size_matches(B, &size, 0)?;
let found = search_find(B, &["-type", "f", "-size", size.as_str()])?;
search_list(B, found)
}
#[path = "code_search.rs"]
mod code_search;
fn bi_search_duplicates(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
code_search::run("search_duplicates", args, input)
}
fn bi_search_todos(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let found = search_grep("search_todos", "TODO")?;
let todos: Vec<Value> = found
.iter()
.take(100)
.map(|l| Value::Str(l.to_string()))
.collect();
Ok(Value::Array(todos))
}
fn bi_search_fixmes(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let found = search_grep("search_fixmes", "FIXME")?;
let fixmes: Vec<Value> = found
.iter()
.take(100)
.map(|l| Value::Str(l.to_string()))
.collect();
Ok(Value::Array(fixmes))
}
fn bi_search_semantic(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
code_search::run("search_semantic", args, input)
}
fn bi_search_similar(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
code_search::run("search_similar", args, input)
}
// Test and diagnostics builtins run cargo on an explicit project directory
// inside the workspace; see cargo_project.rs for the checks and the deadline.
#[path = "cargo_project.rs"]
mod cargo_project;
use cargo_project::{at_level, cargo_done, cargo_text, diagnostics, is_error_level, project_dir};
/// A cargo test selector (a test target or a test-name filter), checked
/// against `allowed` characters beyond letters, digits and `_`. A leading `-`
/// would be read by cargo as an option, so it is refused by the charset.
fn test_selector(
builtin: &str,
what: &str,
value: Option<&Value>,
allowed: &str,
) -> Result<String> {
let s = match value {
Some(Value::Str(s)) => s.clone(),
Some(other) => {
return Err(crate::safety::bad_arg(
builtin,
&format!("{what}: String"),
other.type_name(),
))
}
None => return Err(crate::safety::arg_err(format!("{builtin} requires {what}"))),
};
let well_formed = !s.is_empty()
&& s.len() <= 256
&& !s.starts_with('-')
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || allowed.contains(c));
if !well_formed {
return Err(crate::safety::bad_arg(
builtin,
&format!("{what}: 1 to 256 letters, digits, '_' or {allowed:?}, not starting with '-'"),
&format!("{s:?}"),
));
}
crate::safety::reject_option_like(builtin, std::slice::from_ref(&s))?;
Ok(s)
}
// Test module implementations
fn bi_test_run(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "test_run";
k8s_no_input(B, &input)?;
let dir = project_dir(B, args.first())?;
let out = cargo_text(B, &dir, &["test"], Keep::Tail(TOOL_KEEP_TAIL))?;
Ok(Value::Str(out))
}
fn bi_test_run_file(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "test_run_file";
k8s_no_input(B, &input)?;
let dir = project_dir(B, args.first())?;
let file = test_selector(
B,
"file: the name of an integration test target",
args.get(1),
"-",
)?;
let out = cargo_text(
B,
&dir,
&["test", "--test", &file],
Keep::Tail(TOOL_KEEP_TAIL),
)?;
Ok(Value::Str(out))
}
fn bi_test_run_function(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "test_run_function";
k8s_no_input(B, &input)?;
let dir = project_dir(B, args.first())?;
let name = test_selector(B, "name: a test name or path filter", args.get(1), ":")?;
let out = cargo_text(B, &dir, &["test", &name], Keep::Tail(TOOL_KEEP_TAIL))?;
Ok(Value::Str(out))
}
fn bi_test_list(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "test_list";
k8s_no_input(B, &input)?;
let dir = project_dir(B, args.first())?;
// Head, not tail: a listing cut short would look like a complete one.
let out = cargo_text(
B,
&dir,
&["test", "--", "--list"],
Keep::Head(TOOL_MAX_TEXT),
)?;
let tests: Vec<Value> = out
.lines()
.filter_map(|l| l.strip_suffix(": test"))
.map(|name| Value::Str(name.to_string()))
.collect();
Ok(Value::Array(tests))
}
#[path = "test_reports.rs"]
mod test_reports;
fn bi_test_coverage(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
test_reports::coverage(args, input)
}
fn bi_test_failing(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
test_reports::outcomes("test_failing", args, input)
}
fn bi_test_passing(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
test_reports::outcomes("test_passing", args, input)
}
fn bi_test_skipped(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
test_reports::outcomes("test_skipped", args, input)
}
#[path = "test_watch.rs"]
mod test_watch;
fn bi_test_watch(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
test_watch::run("test_watch", args, input)
}
fn bi_test_bench(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "test_bench";
k8s_no_input(B, &input)?;
let dir = project_dir(B, args.first())?;
let out = cargo_text(B, &dir, &["bench"], Keep::Tail(TOOL_KEEP_TAIL))?;
Ok(Value::Str(out))
}
#[path = "testing_backend.rs"]
mod testing_backend;
fn bi_test_snapshot(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
testing_backend::run("test_snapshot", args, input)
}
fn bi_test_generate(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
testing_backend::run("test_generate", args, input)
}
// Diagnostics module implementations
/// diag_check(dir) - cargo check's errors and warnings, as Records.
fn bi_diag_check(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "diag_check";
k8s_no_input(B, &input)?;
let dir = project_dir(B, args.first())?;
Ok(Value::Array(diagnostics(B, &dir, &["check"])?))
}
/// diag_lint(dir) - cargo clippy's errors and warnings, as Records.
fn bi_diag_lint(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "diag_lint";
k8s_no_input(B, &input)?;
let dir = project_dir(B, args.first())?;
Ok(Value::Array(diagnostics(B, &dir, &["clippy"])?))
}
/// diag_errors(dir) - only the errors of cargo check.
fn bi_diag_errors(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "diag_errors";
k8s_no_input(B, &input)?;
let dir = project_dir(B, args.first())?;
let found = diagnostics(B, &dir, &["check"])?;
Ok(Value::Array(at_level(found, is_error_level)))
}
/// diag_warnings(dir) - only the warnings of cargo check.
fn bi_diag_warnings(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "diag_warnings";
k8s_no_input(B, &input)?;
let dir = project_dir(B, args.first())?;
let found = diagnostics(B, &dir, &["check"])?;
Ok(Value::Array(at_level(found, |l| l == "warning")))
}
/// diag_all(dir) - cargo check over every target: library, binaries, tests,
/// examples and benches. Compiler errors in test code appear here and not in
/// diag_check, which checks the default targets.
fn bi_diag_all(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "diag_all";
k8s_no_input(B, &input)?;
let dir = project_dir(B, args.first())?;
Ok(Value::Array(diagnostics(
B,
&dir,
&["check", "--all-targets"],
)?))
}
/// diag_fix(dir) - cargo fix, which applies the compiler's machine-applicable
/// suggestions to the project's files. It refuses a version-controlled tree
/// with uncommitted changes, as cargo does by default; --allow-dirty is not
/// passed, so the rewrite never lands on uncommitted work silently.
fn bi_diag_fix(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "diag_fix";
k8s_no_input(B, &input)?;
let dir = project_dir(B, args.first())?;
cargo_done(B, &dir, &["fix"])?;
Ok(Value::Bool(true))
}
fn bi_diag_explain(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let code = args
.first()
.and_then(|v| v.as_str().ok())
.map(|s| s.to_string())
.ok_or_else(|| crate::safety::arg_err("error code required"))?;
crate::safety::reject_option_like("diag_explain", std::slice::from_ref(&code))?;
let mut cmd = crate::safety::child_command("rustc");
cmd.args(["--explain", &code]);
let output = bounded_cmd_output("diag_explain", "rustc", cmd, TOOL_DEADLINE_SECS)?;
// A code rustc does not know ("E9999") answered "".
if !output.status.success() {
return Err(crate::safety::tool_failed(
"diag_explain",
"rustc --explain",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
Ok(Value::Str(
String::from_utf8_lossy(&output.stdout).to_string(),
))
}
fn bi_diag_suppress(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
diagnostic_filter::run(args, input)
}
#[path = "diagnostic_filter.rs"]
mod diagnostic_filter;
fn bi_diag_config(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
if let Ok(content) = std::fs::read_to_string("clippy.toml") {
return Ok(Value::Str(content));
}
if let Ok(content) = std::fs::read_to_string(".clippy.toml") {
return Ok(Value::Str(content));
}
Ok(Value::Null)
}
// Refactor module implementations
#[path = "expression_refactor.rs"]
mod expression_refactor;
#[path = "rename_refactor.rs"]
mod rename_refactor;
fn bi_refactor_rename(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
rename_refactor::run("refactor_rename", args, input)
}
/// Renames `from` to `to` without replacing an existing `to`. The check that the
/// destination is free is the caller's, and it is not enough on its own: another
/// process can create `to` between the check and the rename. So the rename
/// itself refuses an existing destination, atomically, where the platform has a
/// primitive for it:
///
/// - Linux and Android: `renameat2` with `RENAME_NOREPLACE`. It applies to files,
/// directories and symlinks alike.
/// - macOS: `renamex_np` with `RENAME_EXCL`. Also every kind of entry.
/// - Windows: `MoveFileExW` with no replace flag, which fails with
/// `ERROR_ALREADY_EXISTS` for a directory as well as a file. `std::fs::rename`
/// passes `MOVEFILE_REPLACE_EXISTING`, which is why it is not used here.
///
/// An existing destination is reported as `AlreadyExists`, whatever the entry.
///
/// Where the primitive is missing or refuses for another reason (a filesystem
/// or kernel without it, `EINVAL` or `ENOSYS` on Linux, `ENOTSUP` or `EINVAL`
/// on macOS, any other failure on Windows), the older fallback runs. A
/// directory, or a symlink, takes the plain `std::fs::rename`, which replaces
/// its destination. A file first gets a hard link to `to`, which fails with
/// `AlreadyExists` when `to` is taken, and only then is `from` removed; if that
/// removal fails the new link is removed too, so the file is left under both
/// names only when that removal fails as well, and the error then says so. A volume
/// that cannot hard-link (FAT, some network shares) falls
/// back to `std::fs::rename` on any hard-link error other than `AlreadyExists`.
/// So on those systems the caller's check is the only guard, as before. On
/// other targets the fallback is all there is.
pub fn rename_file_no_replace(from: &std::path::Path, to: &std::path::Path) -> std::io::Result<()> {
if let Some(done) = rename_no_replace_atomic(from, to) {
return done;
}
if !std::fs::symlink_metadata(from)?.is_file() {
return std::fs::rename(from, to);
}
match std::fs::hard_link(from, to) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => return Err(e),
Err(_) => return std::fs::rename(from, to),
}
if let Err(e) = std::fs::remove_file(from) {
if let Err(undo) = std::fs::remove_file(to) {
if undo.kind() != std::io::ErrorKind::NotFound {
return Err(std::io::Error::new(
e.kind(),
format!(
"{e}; and the new name {} could not be removed, so the file now has both names",
to.display()
),
));
}
}
return Err(e);
}
Ok(())
}
/// The atomic no-replace rename for this platform, or `None` when the primitive
/// is not there or refuses in a way the fallback handles (see
/// `rename_file_no_replace`). `Some` is the final answer.
#[cfg(any(target_os = "linux", target_os = "android"))]
fn rename_no_replace_atomic(
from: &std::path::Path,
to: &std::path::Path,
) -> Option<std::io::Result<()>> {
const RENAME_NOREPLACE: libc::c_uint = 1;
let (from, to) = match c_paths(from, to) {
Ok(pair) => pair,
Err(e) => return Some(Err(e)),
};
// SAFETY: both strings are NUL-terminated and outlive the call. AT_FDCWD makes
// relative paths resolve against the working directory, as std::fs does.
let rc = unsafe {
libc::syscall(
libc::SYS_renameat2,
libc::AT_FDCWD,
from.as_ptr(),
libc::AT_FDCWD,
to.as_ptr(),
RENAME_NOREPLACE,
)
};
if rc == 0 {
return Some(Ok(()));
}
let err = std::io::Error::last_os_error();
match err.raw_os_error() {
// No renameat2 in the kernel (ENOSYS), or no RENAME_NOREPLACE on this
// filesystem (EINVAL): the fallback.
Some(libc::EINVAL) | Some(libc::ENOSYS) => None,
_ => Some(Err(err)),
}
}
#[cfg(target_os = "macos")]
fn rename_no_replace_atomic(
from: &std::path::Path,
to: &std::path::Path,
) -> Option<std::io::Result<()>> {
extern "C" {
fn renamex_np(
from: *const libc::c_char,
to: *const libc::c_char,
flags: libc::c_uint,
) -> libc::c_int;
}
const RENAME_EXCL: libc::c_uint = 0x4;
let (from, to) = match c_paths(from, to) {
Ok(pair) => pair,
Err(e) => return Some(Err(e)),
};
// SAFETY: both strings are NUL-terminated and outlive the call.
let rc = unsafe { renamex_np(from.as_ptr(), to.as_ptr(), RENAME_EXCL) };
if rc == 0 {
return Some(Ok(()));
}
let err = std::io::Error::last_os_error();
match err.raw_os_error() {
// The volume does not support exclusive renames: the fallback.
Some(libc::ENOTSUP) | Some(libc::EINVAL) => None,
_ => Some(Err(err)),
}
}
#[cfg(windows)]
fn rename_no_replace_atomic(
from: &std::path::Path,
to: &std::path::Path,
) -> Option<std::io::Result<()>> {
extern "system" {
fn MoveFileExW(existing: *const u16, new: *const u16, flags: u32) -> i32;
}
let from = match wide_path(from) {
Ok(w) => w,
Err(e) => return Some(Err(e)),
};
let to = match wide_path(to) {
Ok(w) => w,
Err(e) => return Some(Err(e)),
};
// SAFETY: both buffers are NUL-terminated and outlive the call. Flags 0 means
// no MOVEFILE_REPLACE_EXISTING, so an existing destination is an error.
let ok = unsafe { MoveFileExW(from.as_ptr(), to.as_ptr(), 0) };
if ok != 0 {
return Some(Ok(()));
}
let err = std::io::Error::last_os_error();
match err.raw_os_error() {
// ERROR_FILE_EXISTS and ERROR_ALREADY_EXISTS: the destination is taken.
Some(80) | Some(183) => Some(Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
err,
))),
_ => None,
}
}
#[cfg(not(any(
target_os = "linux",
target_os = "android",
target_os = "macos",
windows
)))]
fn rename_no_replace_atomic(
_from: &std::path::Path,
_to: &std::path::Path,
) -> Option<std::io::Result<()>> {
None
}
/// Both paths as NUL-terminated C strings. A path with an interior NUL cannot be
/// passed to the kernel, so it is refused as `InvalidInput`.
#[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))]
fn c_paths(
from: &std::path::Path,
to: &std::path::Path,
) -> std::io::Result<(std::ffi::CString, std::ffi::CString)> {
use std::os::unix::ffi::OsStrExt;
let bad = || std::io::Error::from(std::io::ErrorKind::InvalidInput);
let from = std::ffi::CString::new(from.as_os_str().as_bytes()).map_err(|_| bad())?;
let to = std::ffi::CString::new(to.as_os_str().as_bytes()).map_err(|_| bad())?;
Ok((from, to))
}
/// A path as NUL-terminated UTF-16, for the Windows calls. An interior NUL would
/// cut the path short, so it is refused as `InvalidInput`.
#[cfg(windows)]
fn wide_path(path: &std::path::Path) -> std::io::Result<Vec<u16>> {
use std::os::windows::ffi::OsStrExt;
let mut wide: Vec<u16> = path.as_os_str().encode_wide().collect();
if wide.contains(&0) {
return Err(std::io::Error::from(std::io::ErrorKind::InvalidInput));
}
wide.push(0);
Ok(wide)
}
#[cfg(test)]
mod rename_no_replace_tests {
use super::rename_file_no_replace;
use std::io::ErrorKind;
use std::path::PathBuf;
/// A scratch directory for one test, removed when the test ends.
struct Scratch(PathBuf);
impl Scratch {
fn new(tag: &str) -> Self {
let dir = std::env::temp_dir()
.join(format!("ish-rename-noreplace-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Scratch(dir)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// A directory renamed onto an existing empty directory is refused, and both
/// directories are as they were. On Windows this is the case that a plain
/// `std::fs::rename` (MOVEFILE_REPLACE_EXISTING) would change.
#[test]
fn directory_onto_an_existing_empty_directory_is_refused_and_both_stay() {
let s = Scratch::new("dir_onto_empty_dir");
let src = s.0.join("src_dir");
let dst = s.0.join("dst_dir");
std::fs::create_dir(&src).unwrap();
std::fs::write(src.join("marker.txt"), "src").unwrap();
std::fs::create_dir(&dst).unwrap();
let err =
rename_file_no_replace(&src, &dst).expect_err("an existing destination must refuse");
assert_eq!(err.kind(), ErrorKind::AlreadyExists);
assert_eq!(
std::fs::read_to_string(src.join("marker.txt")).unwrap(),
"src",
"source directory changed"
);
assert!(dst.is_dir(), "destination directory was removed");
assert_eq!(
std::fs::read_dir(&dst).unwrap().count(),
0,
"destination directory gained entries"
);
}
/// A file renamed onto an existing file is refused, and both are as they were.
#[test]
fn file_onto_an_existing_file_is_refused_and_both_stay() {
let s = Scratch::new("file_onto_file");
let a = s.0.join("a.txt");
let b = s.0.join("b.txt");
std::fs::write(&a, "alpha").unwrap();
std::fs::write(&b, "beta").unwrap();
let err = rename_file_no_replace(&a, &b).expect_err("an existing destination must refuse");
assert_eq!(err.kind(), ErrorKind::AlreadyExists);
assert_eq!(
std::fs::read_to_string(&a).unwrap(),
"alpha",
"source changed"
);
assert_eq!(
std::fs::read_to_string(&b).unwrap(),
"beta",
"destination replaced"
);
}
/// A directory renamed to a free name moves, and nothing is left at the source.
#[test]
fn directory_to_a_free_name_succeeds() {
let s = Scratch::new("dir_free");
let src = s.0.join("src_dir");
let dst = s.0.join("moved_dir");
std::fs::create_dir(&src).unwrap();
std::fs::write(src.join("marker.txt"), "src").unwrap();
rename_file_no_replace(&src, &dst).expect("rename to a free name");
assert!(!src.exists(), "source still present");
assert_eq!(
std::fs::read_to_string(dst.join("marker.txt")).unwrap(),
"src"
);
}
}
/// refactor_rename_file(from, to) - rename a file or directory inside the
/// workspace. The destination must not exist: the rename would otherwise
/// replace it without a word. Both paths are inside the workspace, checked
/// before anything changes, and the rename is recorded for an active
/// transaction.
fn bi_refactor_rename_file(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "refactor_rename_file";
k8s_no_input(B, &input)?;
let from = cargo_project::in_workspace_path(B, "from", args.first())?;
let to = cargo_project::in_workspace_path(B, "to", args.get(1))?;
if from == to {
return Err(crate::safety::bad_arg(
B,
"a destination that differs from the source",
"the same path",
));
}
if std::fs::symlink_metadata(&from).is_err() {
return Err(crate::safety::not_found(B, "file or directory", &from));
}
if std::fs::symlink_metadata(&to).is_ok() {
return Err(crate::safety::bad_arg(
B,
"a destination that does not exist yet",
&format!("the existing path {to}"),
));
}
// The containment above is the first check, and it does not see protected
// paths: a file moved to `.git/hooks/*` or into `.ironshell/` is inside the
// workspace and used to pass. `guard_local_write` applies the same jail and
// protected-path refusal that file_write and the other WriteLocal writers
// use, and audits both ends. The rename then uses the paths it checked.
let from = guard_local_write(B, &from)?;
let to = guard_local_write(B, &to)?;
crate::tx::snapshot(&from);
crate::tx::snapshot(&to);
// The destination was checked free above. `rename_file_no_replace` keeps it free
// at the rename too, where a plain rename would replace one that appeared since.
match rename_file_no_replace(std::path::Path::new(&from), std::path::Path::new(&to)) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
return Err(crate::safety::bad_arg(
B,
"a destination that does not exist yet",
&format!("the existing path {to}"),
));
}
Err(e) => return Err(crate::safety::fs_error(B, &from, &e)),
}
Ok(Value::Bool(true))
}
#[path = "extract_function_refactor.rs"]
mod extract_function_refactor;
fn bi_refactor_extract_function(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
extract_function_refactor::run("refactor_extract_function", args, input)
}
fn bi_refactor_extract_variable(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
expression_refactor::run("refactor_extract_variable", args, input)
}
fn bi_refactor_extract_constant(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
expression_refactor::run("refactor_extract_constant", args, input)
}
fn bi_refactor_inline(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
expression_refactor::run("refactor_inline", args, input)
}
#[path = "move_refactor.rs"]
mod move_refactor;
fn bi_refactor_move_to_file(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
move_refactor::run("refactor_move_to_file", args, input)
}
#[path = "signature_refactor.rs"]
mod signature_refactor;
fn bi_refactor_change_signature(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
signature_refactor::run("refactor_change_signature", args, input)
}
fn bi_refactor_add_parameter(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
signature_refactor::run("refactor_add_parameter", args, input)
}
fn bi_refactor_remove_parameter(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
signature_refactor::run("refactor_remove_parameter", args, input)
}
fn bi_refactor_organize_imports(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let file = args
.first()
.and_then(|v| v.as_str().ok())
.map(|s| s.to_string())
.ok_or_else(|| crate::safety::arg_err("file required"))?;
let content = read_text_capped("refactor_organize_imports", &file)?;
let mut imports: Vec<&str> = content.lines().filter(|l| l.starts_with("use ")).collect();
imports.sort();
Ok(Value::Array(
imports
.into_iter()
.map(|s| Value::Str(s.to_string()))
.collect(),
))
}
/// refactor_remove_unused(dir) - cargo fix over one project.
///
/// This is not a targeted removal. `cargo fix` applies every machine-applicable
/// suggestion the compiler gives, and removing unused imports is one of them; so
/// are the other fixes for the lints that are enabled. The earlier body also
/// passed --allow-dirty, so it rewrote uncommitted work. Like diag_fix, this
/// refuses a version-controlled tree with uncommitted changes.
fn bi_refactor_remove_unused(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "refactor_remove_unused";
k8s_no_input(B, &input)?;
let dir = project_dir(B, args.first())?;
cargo_done(B, &dir, &["fix"])?;
Ok(Value::Bool(true))
}
// Session module implementations
use std::sync::atomic::{AtomicU64, Ordering};
static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
fn bi_session_start(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let id = SESSION_COUNTER.fetch_add(1, Ordering::SeqCst);
Ok(Value::Int(id as i64))
}
/// Refuse arguments and pipeline input for a builtin that takes neither.
fn no_args_or_input(name: &str, args: &[Value], input: &Option<Value>) -> Result<()> {
if !args.is_empty() {
return Err(crate::safety::bad_arg(
name,
"no arguments",
&format!("{} argument(s)", args.len()),
));
}
if input.is_some() {
return Err(crate::safety::bad_arg(
name,
"no pipeline input",
"piped input",
));
}
Ok(())
}
/// The optional step count of `session_undo` and `session_redo`: none means 1,
/// otherwise one Int from 1 to 10000. Anything else is refused, not clamped.
fn step_count_arg(name: &str, args: &[Value], input: &Option<Value>) -> Result<usize> {
if input.is_some() {
return Err(crate::safety::bad_arg(
name,
"no pipeline input",
"piped input",
));
}
match args {
[] => Ok(1),
[Value::Int(n)] if (1..=10_000).contains(n) => Ok(*n as usize),
[Value::Int(n)] => Err(crate::safety::bad_arg(
name,
"a step count from 1 to 10000",
&n.to_string(),
)),
[other] => Err(crate::safety::bad_arg(
name,
"an Int step count",
other.type_name(),
)),
more => Err(crate::safety::bad_arg(
name,
"at most one step count",
&format!("{} arguments", more.len()),
)),
}
}
/// `session_end()` — keep every change and forget the journal. No files are
/// touched; after this, nothing recorded can be undone or redone.
fn bi_session_end(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
no_args_or_input("session_end", &args, &input)?;
let entries = crate::journal::entries();
let kept = entries.len();
let irreversible = entries.iter().filter(|e| !e.reversible()).count();
let discarded_redo = crate::journal::redo_count();
clear_journal_or_refuse("session_end")?;
let mut m = std::collections::BTreeMap::new();
m.insert("kept".to_string(), Value::Int(kept as i64));
m.insert("irreversible".to_string(), Value::Int(irreversible as i64));
m.insert(
"discarded_redo".to_string(),
Value::Int(discarded_redo as i64),
);
m.insert(
"note".to_string(),
Value::Str("no files changed; these steps can no longer be undone or redone".to_string()),
);
Ok(Value::Record(m))
}
fn bi_session_id(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
Ok(Value::Int(SESSION_COUNTER.load(Ordering::SeqCst) as i64))
}
/// `session_history()` — the steps that can be undone (`done`) and the steps
/// that can be re-applied (`undone`, most recent last).
fn bi_session_history(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
no_args_or_input("session_history", &args, &input)?;
let done = crate::journal::entries();
let undone: Vec<Value> = crate::journal::undone()
.iter()
.map(|r| {
let mut m = std::collections::BTreeMap::new();
m.insert("seq".to_string(), Value::Int(r.seq as i64));
m.insert("builtin".to_string(), Value::Str(r.builtin.clone()));
m.insert("effect".to_string(), Value::Str(r.effect.clone()));
if !r.path.is_empty() {
m.insert("path".to_string(), Value::Str(r.path.clone()));
}
Value::Record(m)
})
.collect();
let mut out = std::collections::BTreeMap::new();
out.insert(
"enabled".to_string(),
Value::Bool(crate::journal::enabled()),
);
out.insert("done".to_string(), Value::Array(journal_rows(&done)));
out.insert("undone".to_string(), Value::Array(undone));
Ok(Value::Record(out))
}
/// `session_undo(n = 1)` — the same record `undo(n)` returns.
fn bi_session_undo(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let n = step_count_arg("session_undo", &args, &input)?;
Ok(Value::Record(rewind_record(
&crate::journal::undo(n),
"restored",
)))
}
/// `session_redo(n = 1)` — re-apply the last `n` undone steps.
fn bi_session_redo(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let n = step_count_arg("session_redo", &args, &input)?;
Ok(Value::Record(rewind_record(
&crate::journal::redo(n),
"reapplied",
)))
}
fn bi_session_checkpoint(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("session_checkpoint", &input)?;
// A non-string label used to fall back silently to "checkpoint".
let name = match args.first() {
Some(v) => expect_string("session_checkpoint", v)?.to_string(),
None => "checkpoint".to_string(),
};
// This ran `git stash push`, which saves the changes and then REMOVES
// them from the working tree: a "checkpoint" that erased the work it was
// meant to protect. With nothing to save git exits 0, so it also answered
// true having saved nothing. `stash create` records the changes without
// touching the tree; `stash store` keeps that record in the stash list.
// Both git calls are checked the same way: outside a repository these
// are E_BAD_STATE, a failed git is E_TOOL_FAILED with its stderr tail.
let mut cmd = git_command();
cmd.args(["stash", "create"]);
let output = bounded_cmd_output("session_checkpoint", "git", cmd, TOOL_DEADLINE_SECS)?;
git_succeeded("session_checkpoint", &output, ".")?;
let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
if sha.is_empty() {
// Not an error: the tree has no changes, so there is nothing to keep.
return Ok(Value::Bool(false));
}
let mut cmd = git_command();
cmd.args(["stash", "store", "-m", &name, &sha]);
let output = bounded_cmd_output("session_checkpoint", "git", cmd, TOOL_DEADLINE_SECS)?;
git_succeeded("session_checkpoint", &output, ".")?;
Ok(Value::Bool(true))
}
fn bi_session_restore(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("session_restore", &input)?;
let index = args
.first()
.map(|v| expect_int("session_restore", v))
.transpose()?
.unwrap_or(0);
let mut cmd = git_command();
cmd.args([
"rev-parse",
"--verify",
"--quiet",
&format!("stash@{{{}}}", index),
]);
let check = bounded_cmd_output("session_restore", "git", cmd, TOOL_DEADLINE_SECS)?;
if !check.status.success() {
// A missing checkpoint is not a git failure: git's own answer for it
// is exit 1 with no text, which the generic path reported as E_TOOL_FAILED.
let mut cmd = git_command();
cmd.args(["rev-parse", "--is-inside-work-tree"]);
let in_repo = bounded_cmd_output("session_restore", "git", cmd, TOOL_DEADLINE_SECS)
.is_ok_and(|o| o.status.success());
if in_repo {
return Err(crate::safety::not_found(
"session_restore",
"checkpoint",
&index.to_string(),
));
}
git_succeeded("session_restore", &check, ".")?;
}
let mut cmd = git_command();
cmd.args(["stash", "apply", &format!("stash@{{{}}}", index)]);
let output = bounded_cmd_output("session_restore", "git", cmd, TOOL_DEADLINE_SECS)?;
// `false` said "not restored" and dropped why: no repository, no such
// checkpoint, or a conflict. Those need different next steps.
git_succeeded("session_restore", &output, ".")?;
Ok(Value::Bool(true))
}
fn bi_session_checkpoints(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("session_checkpoints", &input)?;
bi_git_stash_list(args, None)
}
fn bi_session_diff_since(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("session_diff_since", &input)?;
let commit = args
.first()
.map(|v| expect_string("session_diff_since", v).map(str::to_string))
.transpose()?
.unwrap_or("HEAD~1".to_string());
crate::safety::reject_option_like("session_diff_since", std::slice::from_ref(&commit))?;
let mut cmd = git_command();
cmd.args(["diff", "--no-ext-diff", "--no-textconv", &commit]);
let output = bounded_cmd_output("session_diff_since", "git", cmd, TOOL_DEADLINE_SECS)?;
git_succeeded("session_diff_since", &output, ".")?;
Ok(Value::Str(
String::from_utf8_lossy(&output.stdout).to_string(),
))
}
fn bi_session_changes(_args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("session_changes", &input)?;
bi_git_status(vec![], None)
}
fn bi_session_rollback(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("session_rollback", &input)?;
// A non-string commit used to fall back silently to HEAD and still reset.
let commit = match args.first() {
Some(v) => expect_string("session_rollback", v)?.to_string(),
None => "HEAD".to_string(),
};
crate::safety::reject_option_like("session_rollback", std::slice::from_ref(&commit))?;
let mut cmd = git_command();
cmd.args(["reset", "--hard", &commit]);
let output = bounded_cmd_output("session_rollback", "git", cmd, TOOL_DEADLINE_SECS)?;
// Was `Bool(success)`: a failed reset answered false with no reason.
git_succeeded("session_rollback", &output, ".")?;
Ok(Value::Bool(true))
}
fn bi_session_export(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
git_refuse_input("session_export", &input)?;
let file = args
.first()
.map(|v| expect_string("session_export", v).map(str::to_string))
.transpose()?
.unwrap_or("session.patch".to_string());
// Same again: labelled `ReadLocal`, writes an arbitrary path.
let file = guard_local_write("session_export", &file)?;
let mut cmd = git_command();
cmd.args(["diff", "--no-ext-diff", "--no-textconv"]);
let output = bounded_cmd_output("session_export", "git", cmd, TOOL_DEADLINE_SECS)?;
git_succeeded("session_export", &output, ".")?;
crate::tx::snapshot(&file); // record pre-export state if a transaction is active
write_file_atomically("session_export", &file, &output.stdout)?;
Ok(Value::Str(file))
}
// Docs module implementations
/// docs_generate(dir) - cargo doc --no-deps for one project. Like every cargo
/// call here it runs in that directory, under the long deadline.
fn bi_docs_generate(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "docs_generate";
k8s_no_input(B, &input)?;
let dir = project_dir(B, args.first())?;
cargo_done(B, &dir, &["doc", "--no-deps"])?;
Ok(Value::Bool(true))
}
fn bi_docs_search(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// In-process, as "file:line: text" like the grep it replaces. That grep
// took the query as a pattern (a leading `-` was an option), does not
// exist on Windows, and exited 2 with [] when there were no docs, which
// read as "no matches".
let query = args
.first()
.and_then(|v| v.as_str().ok())
.map(|s| s.to_string())
.ok_or_else(|| crate::safety::arg_err("query required"))?;
let root = std::path::Path::new("target/doc");
if !entry_is_dir("docs_search", root)? {
return Err(crate::safety::not_found(
"docs_search",
"documentation directory",
"target/doc (run `cargo doc` first)",
));
}
const LIMIT: usize = 20;
let mut matches = Vec::new();
let mut stack = vec![root.to_path_buf()];
// An unreadable directory or entry used to be skipped, so the match list
// looked complete when it was not. Those are errors now. A file that is
// not UTF-8 is searched as lossy text, not skipped.
while let Some(dir) = stack.pop() {
let mut entries = Vec::new();
for entry in
std::fs::read_dir(&dir).map_err(|e| crate::safety::fs_error("docs_search", &dir, &e))?
{
let entry = entry.map_err(|e| crate::safety::fs_error("docs_search", &dir, &e))?;
entries.push(entry.path());
}
entries.sort();
for p in entries {
if entry_is_dir("docs_search", &p)? {
stack.push(p);
continue;
}
// Capped like every whole-file read: a doc over the limit is an
// error, not a search of part of it.
let bytes = match read_bytes_capped("docs_search", &p) {
Ok(bytes) => bytes,
// A dangling link has no text to search.
Err(e) if capped_read_not_found(&e) => continue,
Err(e) => return Err(e),
};
let text = String::from_utf8_lossy(&bytes);
for (i, line) in text.lines().enumerate() {
if line.contains(&query) {
matches.push(Value::Str(format!(
"{}:{}: {}",
p.display(),
i + 1,
line.trim()
)));
if matches.len() == LIMIT {
return Ok(Value::Array(matches));
}
}
}
}
}
Ok(Value::Array(matches))
}
fn bi_docs_api(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
code_search::run("docs_api", args, input)
}
fn bi_docs_readme(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
bi_project_readme(_args, _input)
}
fn bi_docs_changelog(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
first_project_text(
"docs_changelog",
&["CHANGELOG.md", "CHANGELOG", "CHANGES.md", "HISTORY.md"],
)
}
fn bi_docs_examples(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut examples = Vec::new();
if std::path::Path::new("examples").is_dir() {
if let Ok(entries) = std::fs::read_dir("examples") {
for entry in entries.flatten() {
examples.push(Value::Str(entry.path().to_string_lossy().to_string()));
}
}
}
// read_dir's order is the filesystem's, not a stable one.
examples.sort_by_key(|v| v.to_display_string());
Ok(Value::Array(examples))
}
fn bi_docs_types(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
extract_rust_documentation("docs_types", args, input, false)
}
fn bi_docs_signatures(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
extract_rust_documentation("docs_signatures", args, input, true)
}
fn extract_rust_documentation(
name: &str,
args: Vec<Value>,
input: Option<Value>,
functions: bool,
) -> Result<Value> {
use quote::ToTokens;
if args.len() != 1 || input.is_some() {
return Err(crate::safety::bad_arg(
name,
"one Rust source String; no pipeline input",
"invalid call shape",
));
}
let Value::Str(source) = &args[0] else {
return Err(crate::safety::bad_arg(
name,
"Rust source String",
args[0].type_name(),
));
};
if source.len() > 1024 * 1024 {
return Err(crate::safety::budget_exceeded(
name,
"Rust source exceeds 1 MiB".into(),
"extract from a smaller source unit",
));
}
// Conservative complexity preflight before token-tree/AST construction.
// Count punctuation even inside strings/comments so they cannot hide nested
// syntax from this budget. Callers can submit smaller source units.
if source
.bytes()
.filter(u8::is_ascii_punctuation)
.take(129)
.count()
> 128
{
return Err(crate::safety::budget_exceeded(name, "raw syntax punctuation exceeds 128".into(), "extract from smaller source units; the conservative budget includes strings and comments"));
}
let file = syn::parse_file(source)
.map_err(|_| crate::safety::bad_arg(name, "valid Rust source", "syntax error"))?;
let mut rows = Vec::new();
for item in file.items {
let mut row = std::collections::BTreeMap::new();
if functions {
if let syn::Item::Fn(function) = item {
row.insert("name".into(), Value::Str(function.sig.ident.to_string()));
row.insert(
"signature".into(),
Value::Str(function.sig.to_token_stream().to_string()),
);
row.insert(
"async".into(),
Value::Bool(function.sig.asyncness.is_some()),
);
} else {
continue;
}
} else {
let (ident, kind) = match &item {
syn::Item::Struct(x) => (&x.ident, "struct"),
syn::Item::Enum(x) => (&x.ident, "enum"),
syn::Item::Union(x) => (&x.ident, "union"),
syn::Item::Trait(x) => (&x.ident, "trait"),
syn::Item::Type(x) => (&x.ident, "alias"),
_ => continue,
};
row.insert("name".into(), Value::Str(ident.to_string()));
row.insert("kind".into(), Value::Str(kind.into()));
row.insert(
"definition".into(),
Value::Str(item.to_token_stream().to_string()),
);
}
rows.push(Value::Record(row));
}
rows.sort_by(|a, b| {
let Value::Record(a) = a else { unreachable!() };
let Value::Record(b) = b else { unreachable!() };
a.get("name")
.unwrap()
.to_display_string()
.cmp(&b.get("name").unwrap().to_display_string())
});
Ok(Value::Array(rows))
}
// Environment module implementations
fn bi_env_detect(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut env = BTreeMap::new();
env.insert(
"os".to_string(),
Value::Str(std::env::consts::OS.to_string()),
);
env.insert(
"arch".to_string(),
Value::Str(std::env::consts::ARCH.to_string()),
);
env.insert(
"family".to_string(),
Value::Str(std::env::consts::FAMILY.to_string()),
);
Ok(Value::Record(env))
}
fn bi_env_python(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut cmd = crate::safety::child_command("python");
cmd.args(["--version"]);
let output = bounded_cmd_output("env_python", "python", cmd, PROBE_DEADLINE_SECS)?;
// The exit status was ignored, so a failed command's output came
// back as the answer. On WSL, `docker` is a shim that exists,
// prints "could not be found in this WSL 2 distro" and exits
// non-zero -- and this returned that sentence as the version.
if !output.status.success() {
return Err(crate::safety::tool_failed(
"env_python",
"python",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
// Several of these print their version to stderr rather than
// stdout, so an empty stdout on a SUCCESSFUL run is not an error.
let out = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !out.is_empty() {
return Ok(Value::Str(out));
}
Ok(Value::Str(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
))
}
fn bi_env_node(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut cmd = crate::safety::child_command("node");
cmd.args(["--version"]);
let output = bounded_cmd_output("env_node", "node", cmd, PROBE_DEADLINE_SECS)?;
// The exit status was ignored, so a failed command's output came
// back as the answer. On WSL, `docker` is a shim that exists,
// prints "could not be found in this WSL 2 distro" and exits
// non-zero -- and this returned that sentence as the version.
if !output.status.success() {
return Err(crate::safety::tool_failed(
"env_node",
"node",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
// Several of these print their version to stderr rather than
// stdout, so an empty stdout on a SUCCESSFUL run is not an error.
let out = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !out.is_empty() {
return Ok(Value::Str(out));
}
Ok(Value::Str(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
))
}
fn bi_env_rust(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut cmd = crate::safety::child_command("rustc");
cmd.args(["--version"]);
let output = bounded_cmd_output("env_rust", "rustc", cmd, PROBE_DEADLINE_SECS)?;
// The exit status was ignored, so a failed command's output came
// back as the answer. On WSL, `docker` is a shim that exists,
// prints "could not be found in this WSL 2 distro" and exits
// non-zero -- and this returned that sentence as the version.
if !output.status.success() {
return Err(crate::safety::tool_failed(
"env_rust",
"rustc",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
// Several of these print their version to stderr rather than
// stdout, so an empty stdout on a SUCCESSFUL run is not an error.
let out = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !out.is_empty() {
return Ok(Value::Str(out));
}
Ok(Value::Str(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
))
}
fn bi_env_go(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut cmd = crate::safety::child_command("go");
cmd.args(["version"]);
cmd.env("GOTOOLCHAIN", "local");
let output = bounded_cmd_output("env_go", "go", cmd, PROBE_DEADLINE_SECS)?;
// The exit status was ignored, so a failed command's output came
// back as the answer. On WSL, `docker` is a shim that exists,
// prints "could not be found in this WSL 2 distro" and exits
// non-zero -- and this returned that sentence as the version.
if !output.status.success() {
return Err(crate::safety::tool_failed(
"env_go",
"go",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
// Several of these print their version to stderr rather than
// stdout, so an empty stdout on a SUCCESSFUL run is not an error.
let out = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !out.is_empty() {
return Ok(Value::Str(out));
}
Ok(Value::Str(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
))
}
fn bi_env_java(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut cmd = crate::safety::child_command("java");
cmd.args(["--version"]);
let output = bounded_cmd_output("env_java", "java", cmd, PROBE_DEADLINE_SECS)?;
// Same exit-status gap as the rest of the family; this one takes only
// the first line, so a multi-line failure message was truncated into
// something that looked even more like a version string.
if !output.status.success() {
return Err(crate::safety::tool_failed(
"env_java",
"java",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
let first = |s: &str| s.lines().next().unwrap_or("").to_string();
let out = first(&String::from_utf8_lossy(&output.stdout));
if !out.is_empty() {
return Ok(Value::Str(out));
}
// `java -version` writes to stderr on older JDKs.
Ok(Value::Str(first(&String::from_utf8_lossy(&output.stderr))))
}
fn bi_env_dotnet(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut cmd = crate::safety::child_command("dotnet");
cmd.args(["--version"]);
let output = bounded_cmd_output("env_dotnet", "dotnet", cmd, PROBE_DEADLINE_SECS)?;
// The exit status was ignored, so a failed command's output came
// back as the answer. On WSL, `docker` is a shim that exists,
// prints "could not be found in this WSL 2 distro" and exits
// non-zero -- and this returned that sentence as the version.
if !output.status.success() {
return Err(crate::safety::tool_failed(
"env_dotnet",
"dotnet",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
// Several of these print their version to stderr rather than
// stdout, so an empty stdout on a SUCCESSFUL run is not an error.
let out = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !out.is_empty() {
return Ok(Value::Str(out));
}
Ok(Value::Str(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
))
}
fn bi_env_ruby(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut cmd = crate::safety::child_command("ruby");
cmd.args(["--version"]);
let output = bounded_cmd_output("env_ruby", "ruby", cmd, PROBE_DEADLINE_SECS)?;
// The exit status was ignored, so a failed command's output came
// back as the answer. On WSL, `docker` is a shim that exists,
// prints "could not be found in this WSL 2 distro" and exits
// non-zero -- and this returned that sentence as the version.
if !output.status.success() {
return Err(crate::safety::tool_failed(
"env_ruby",
"ruby",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
// Several of these print their version to stderr rather than
// stdout, so an empty stdout on a SUCCESSFUL run is not an error.
let out = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !out.is_empty() {
return Ok(Value::Str(out));
}
Ok(Value::Str(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
))
}
fn bi_env_shell(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// Neither variable set was "", which names no shell. It is not an answer.
match std::env::var("SHELL").or_else(|_| std::env::var("COMSPEC")) {
Ok(shell) => Ok(Value::Str(shell)),
Err(_) => Err(crate::safety::not_found(
"env_shell",
"shell",
"SHELL or COMSPEC",
)),
}
}
/// Read one environment variable for a builtin. `Ok(None)` is an unset
/// variable. A variable that is set but not valid UTF-8 is refused: it used
/// to read as unset, which is a different fact from absence.
fn env_read(
builtin: &str,
name: &str,
read: Result<String, std::env::VarError>,
) -> Result<Option<String>> {
match read {
Ok(v) => Ok(Some(v)),
Err(std::env::VarError::NotPresent) => Ok(None),
Err(std::env::VarError::NotUnicode(_)) => Err(crate::safety::bad_state(
builtin,
&format!("environment variable `{name}` is set, but its value is not valid UTF-8"),
"env_set",
)),
}
}
fn bi_env_path(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// A PATH that is not valid UTF-8 read as an empty one.
let path = env_read("env_path", "PATH", std::env::var("PATH"))?.unwrap_or_default();
let parts: Vec<Value> = path
.split(if cfg!(windows) { ";" } else { ":" })
// An empty entry ("a::b", or PATH unset) is no directory.
.filter(|p| !p.is_empty())
.map(|p| Value::Str(p.to_string()))
.collect();
Ok(Value::Array(parts))
}
fn bi_env_var(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let name = args
.first()
.and_then(|v| v.as_str().ok())
.map(|s| s.to_string())
.ok_or_else(|| crate::safety::arg_err("variable name required"))?;
// Unset was "", where env() and sys.env() answer null.
Ok(match env_read("env_var", &name, std::env::var(&name))? {
Some(v) => gate_env_secret(&name, v),
None => Value::Null,
})
}
/// env_vars() - every variable as a Record. In agent mode a value whose name
/// looks like a secret is replaced with a redaction marker, as env_var does.
/// Non-UTF-8 names and values are shown lossily: `std::env::vars` panics on
/// them, so this reads the OS strings.
fn bi_env_vars(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "env_vars";
k8s_no_input(B, &input)?;
if !args.is_empty() {
return Err(crate::safety::bad_arg(
B,
"no arguments",
&format!("{} argument(s)", args.len()),
));
}
let vars: BTreeMap<String, Value> = std::env::vars_os()
.map(|(k, v)| {
let k = k.to_string_lossy().into_owned();
let gated = gate_env_secret(&k, v.to_string_lossy().into_owned());
(k, gated)
})
.collect();
Ok(Value::Record(vars))
}
/// env_set(name, value) - set an environment variable of this process.
fn bi_env_set(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "env_set";
k8s_no_input(B, &input)?;
if args.len() < 2 {
return Err(crate::safety::arg_err("name and value required"));
}
let name = env_name(B, &args[0])?;
refuse_gate_env(B, &name)?;
let value = env_value(B, &args[1])?;
std::env::set_var(&name, &value);
Ok(Value::Bool(true))
}
fn bi_env_unset(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
const B: &str = "env_unset";
let raw = args
.first()
.ok_or_else(|| crate::safety::arg_err("variable name required"))?;
let name = env_name(B, raw)?;
refuse_gate_env(B, &name)?;
std::env::remove_var(&name);
Ok(Value::Bool(true))
}
fn bi_env_docker(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut cmd = crate::safety::child_command("docker");
cmd.args(["--version"]);
let output = bounded_cmd_output("env_docker", "docker", cmd, PROBE_DEADLINE_SECS)?;
// The exit status was ignored, so a failed command's output came
// back as the answer. On WSL, `docker` is a shim that exists,
// prints "could not be found in this WSL 2 distro" and exits
// non-zero -- and this returned that sentence as the version.
if !output.status.success() {
return Err(crate::safety::tool_failed(
"env_docker",
"docker",
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
// Several of these print their version to stderr rather than
// stdout, so an empty stdout on a SUCCESSFUL run is not an error.
let out = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !out.is_empty() {
return Ok(Value::Str(out));
}
Ok(Value::Str(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
))
}
fn bi_env_container(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let in_docker = std::path::Path::new("/.dockerenv").exists();
let in_container = std::env::var("container").is_ok();
Ok(Value::Bool(in_docker || in_container))
}
fn bi_env_venv(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// Null when no virtualenv is active, as `env("VIRTUAL_ENV")` and
// `sys.env(...)` answer for any unset variable. It was "", the one
// exception, so an agent had to learn a second spelling of "absent".
Ok(std::env::var("VIRTUAL_ENV")
.ok()
.filter(|v| !v.is_empty())
.map_or(Value::Null, Value::Str))
}
fn bi_env_activate(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let venv = args
.first()
.and_then(|v| v.as_str().ok())
.map(|s| s.to_string())
.ok_or_else(|| crate::safety::arg_err("venv path required"))?;
let activate = if cfg!(windows) {
format!("{}\\Scripts\\activate", venv)
} else {
format!("{}/bin/activate", venv)
};
Ok(Value::Str(format!("source {}", activate)))
}
// ============================================================================
// Platform Versioning Module - Cross-platform determinism for AI agents
// ============================================================================
/// Path of the platform store: `$HOME/.ironshell/platform_db.json`, or under
/// `%USERPROFILE%` where HOME is unset. HOME relocates it; the tests use that.
///
/// With neither set there is no home to live in. This used to fall back to `.`,
/// which wrote the store into whatever directory the call happened to run from.
fn platform_db_path() -> Result<std::path::PathBuf> {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map_err(|_| {
crate::safety::bad_state(
"platform_db",
"neither HOME nor USERPROFILE is set, so the platform store has no home directory",
"HOME",
)
})?;
Ok(std::path::Path::new(&home)
.join(".ironshell")
.join("platform_db.json"))
}
/// Refuse piped input. The platform builtins answer from the host or from the
/// store, so a value piped into them would be dropped without a word.
fn platform_refuse_input(builtin: &str, input: &Option<Value>) -> Result<()> {
if input.is_some() {
return Err(crate::safety::bad_arg(
builtin,
"no pipeline input",
"piped input",
));
}
Ok(())
}
/// The string argument at `index`, or `None` when it was not passed. Any other
/// type is refused. (The declaration checks types too; this keeps the body from
/// ever substituting a default for a value it was given.)
fn platform_str_arg(builtin: &str, args: &[Value], index: usize) -> Result<Option<String>> {
match args.get(index) {
None => Ok(None),
Some(Value::Str(s)) => Ok(Some(s.clone())),
Some(other) => Err(crate::safety::bad_arg(
builtin,
"a string",
other.type_name(),
)),
}
}
/// Parse the platform store. A store that is not a JSON object is E_BAD_STATE:
/// read as empty and written back, it would lose every entry in it.
fn platform_store_parse(
builtin: &str,
path: &std::path::Path,
content: &str,
) -> Result<serde_json::Map<String, serde_json::Value>> {
serde_json::from_str(content).map_err(|e| {
crate::safety::bad_state(
builtin,
&format!(
"the platform store {} is not valid JSON: {e}",
path.display()
),
"repair or delete the file; nothing was written",
)
})
}
/// Get tool version by running command with --version
/// Whether `name` is an executable on `PATH`, answered from an index of the
/// `PATH` directories that is built once and reused.
///
/// Looking a tool up used to stat every `PATH` entry, once per tool, per call.
/// Under WSL, 107 of 117 `PATH` entries are Windows directories reached over
/// 9P, so `platform_detect_tools` -- sixty `which` lookups -- took 14.5s, and
/// 0.1s with the Windows entries removed. Listing each directory once and
/// answering every later question from memory makes a missing tool free.
///
/// Keyed on the value of `PATH`, so a script that changes `PATH` gets a fresh
/// index rather than a stale answer. On Unix a hit must also be executable;
/// on Windows a name matches with any `PATHEXT` extension, as `which` did.
fn on_path(name: &str) -> bool {
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
static INDEX: Mutex<Option<(String, Arc<HashMap<String, std::path::PathBuf>>)>> =
Mutex::new(None);
if name.is_empty() || name.contains(['/', '\\']) {
return std::path::Path::new(name).is_file();
}
let path_var = std::env::var("PATH").unwrap_or_default();
let index = {
let mut guard = INDEX.lock().unwrap_or_else(|e| e.into_inner());
match guard.as_ref() {
Some((key, idx)) if *key == path_var => Arc::clone(idx),
_ => {
let mut idx = HashMap::new();
for dir in std::env::split_paths(&path_var) {
let Ok(rd) = std::fs::read_dir(&dir) else {
continue;
};
for e in rd.flatten() {
let file = e.file_name().to_string_lossy().to_string();
let key = if cfg!(windows) {
file.to_lowercase()
} else {
file
};
// First occurrence wins, as PATH order does.
idx.entry(key).or_insert_with(|| e.path());
}
}
let idx = Arc::new(idx);
*guard = Some((path_var, Arc::clone(&idx)));
idx
}
}
};
if cfg!(windows) {
let exts = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".into());
let lower = name.to_lowercase();
return index.contains_key(&lower)
|| exts
.split(';')
.filter(|x| !x.is_empty())
.any(|x| index.contains_key(&format!("{lower}{}", x.to_lowercase())));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
return index.get(name).is_some_and(|p| {
std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
});
}
#[allow(unreachable_code)]
index.contains_key(name)
}
/// The Azure CLI's version, read from its installed package metadata.
///
/// Running `az` at all can reach the network. With no version cache -- a new
/// home, anyone's first run -- it fetches the latest release list before
/// answering, `az version` included; the egress gate caught it doing so once
/// the probes stopped sharing the developer's HOME. The installed version is
/// in the name of an `azure_cli-<version>.dist-info` directory, found from the
/// launcher: its own tree (the Windows MSI, a Homebrew keg), or the Python
/// interpreter a launcher script names (`/usr/bin/az` runs
/// `/opt/az/bin/python3` in the Debian package).
fn az_version_offline() -> Option<String> {
use std::path::{Path, PathBuf};
let path_var = std::env::var_os("PATH")?;
let names: &[&str] = if cfg!(windows) {
&["az.cmd", "az.bat", "az"]
} else {
&["az"]
};
let az = std::env::split_paths(&path_var)
.flat_map(|d| names.iter().map(move |n| d.join(n)))
.find(|p| p.is_file())?;
let real = std::fs::canonicalize(&az).unwrap_or(az);
let mut roots: Vec<PathBuf> = real
.ancestors()
.skip(1)
.take(3)
.map(Path::to_path_buf)
.collect();
if let Ok(bytes) = std::fs::read(&real) {
if bytes.len() < 16 * 1024 {
let text = String::from_utf8_lossy(&bytes);
for tok in text.split(|c: char| c.is_whitespace() || c == '"' || c == '\'') {
let tok = tok.trim_start_matches("#!");
if tok.starts_with('/') && tok.contains("python") {
if let Some(prefix) = Path::new(tok).parent().and_then(Path::parent) {
roots.push(prefix.to_path_buf());
}
}
}
}
}
for root in roots {
for lib in ["lib", "Lib", "libexec/lib"] {
let lib = root.join(lib);
let mut site_dirs = vec![lib.join("site-packages")];
if let Ok(rd) = std::fs::read_dir(&lib) {
for e in rd.flatten() {
if e.file_name().to_string_lossy().starts_with("python") {
site_dirs.push(e.path().join("site-packages"));
}
}
}
for sp in site_dirs {
let Ok(rd) = std::fs::read_dir(&sp) else {
continue;
};
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if let Some(v) = name
.strip_prefix("azure_cli-")
.and_then(|r| r.strip_suffix(".dist-info"))
{
return Some(v.to_string());
}
}
}
}
}
None
}
/// Whether the first `bazel` on PATH is bazelisk, which fetches a Bazel
/// release (hundreds of megabytes, over the network) to answer `--version`.
fn resolves_to_bazelisk() -> bool {
let path_var = std::env::var_os("PATH").unwrap_or_default();
std::env::split_paths(&path_var)
.map(|d| d.join(if cfg!(windows) { "bazel.exe" } else { "bazel" }))
.find(|p| p.is_file())
.and_then(|p| std::fs::canonicalize(p).ok())
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_lowercase()))
.is_some_and(|n| n.contains("bazelisk"))
}
/// How long one `<tool> --version` may take. A version string that has not
/// arrived by then is not going to be useful to an agent waiting on it.
const TOOL_VERSION_TIMEOUT_SECS: u64 = 5;
/// The first line of `<tool> --version`, or `None` if the tool is absent,
/// fails, or does not answer in time.
///
/// The exit status used to be ignored, so a tool that ran and failed had its
/// error line recorded as its version. On WSL the `docker` shim exists, prints
/// "The command 'docker' could not be found in this WSL 2 distro" and exits
/// non-zero -- and that sentence would have been reported as Docker's version.
/// It also waited forever: see [`probe_tool_versions`] for what that cost.
///
/// Asking a tool its version must not reach the network, and several did:
/// with IRONSHELL_MAX_NET=0, `benches/agentic/network-egress.mjs` traced
/// connections from the tools themselves. `kubectl version` contacts the API
/// server (localhost:8080 by default); `docker version` asks the daemon, and
/// exits non-zero when none runs, which read as "not installed"; packer
/// checked HashiCorp for updates; vcpkg and the Azure CLI sent telemetry; and
/// `bazel`, when it is bazelisk, downloads Bazel itself. So: client-only
/// subcommands, the vendors' documented opt-outs, and bazelisk is not run.
fn get_tool_version(tool: &str) -> Option<String> {
let version_args: &[&str] = match tool {
"java" => &["-version"],
"go" => &["version"],
"kubectl" => &["version", "--client"],
_ => &["--version"],
};
if !on_path(tool) {
return None;
}
if tool == "az" {
// Never run: see az_version_offline.
return Some(match az_version_offline() {
Some(v) => format!("azure-cli {v}"),
None => "azure-cli (installed; version not found offline)".to_string(),
});
}
if tool == "bazel" && resolves_to_bazelisk() {
return Some("bazelisk (Bazel is downloaded on first use; not run)".to_string());
}
let mut cmd = crate::safety::child_command(tool);
cmd.args(version_args);
for (k, v) in [
("CHECKPOINT_DISABLE", "1"), // HashiCorp: packer, terraform, vault
("VCPKG_DISABLE_METRICS", "1"), // vcpkg
("AZURE_CORE_COLLECT_TELEMETRY", "no"), // az
("DOTNET_CLI_TELEMETRY_OPTOUT", "1"), // dotnet
("POWERSHELL_TELEMETRY_OPTOUT", "1"), // pwsh
("POWERSHELL_UPDATECHECK", "Off"), // pwsh
("GOTOOLCHAIN", "local"), // go: never fetch a toolchain
("HOMEBREW_NO_AUTO_UPDATE", "1"), // brew
("HOMEBREW_NO_ANALYTICS", "1"),
// gcloud: on a first run it resolves metadata.google.internal to ask
// whether it is on Compute Engine; the egress gate caught that lookup.
("CLOUDSDK_CORE_CHECK_GCE_METADATA", "false"),
("CLOUDSDK_COMPONENT_MANAGER_DISABLE_UPDATE_CHECK", "true"),
("CLOUDSDK_CORE_DISABLE_USAGE_REPORTING", "true"), // brew
] {
cmd.env(k, v);
}
let o = crate::safety::output_with_timeout(cmd, TOOL_VERSION_TIMEOUT_SECS).ok()?;
if !o.status.success() {
return None;
}
let out = String::from_utf8_lossy(&o.stdout);
let err = String::from_utf8_lossy(&o.stderr);
// Several tools (`java -version` among them) print to stderr.
let combined = if out.trim().is_empty() { err } else { out };
combined
.lines()
.next()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
/// Every tool in `tools` that answers, with its version, in the input order.
///
/// Probed in parallel. The `platform_*` builtins asked 20-60 tools one after
/// another, each with an unbounded wait, so their cost was the SUM of every
/// probe: 5-108 seconds per call on WSL, where looking up a missing tool walks
/// every PATH entry including the Windows ones over 9P.
/// `platform_tool_versions` took 108 seconds. In parallel, with each probe
/// bounded, the cost is roughly the slowest single tool.
fn probe_tool_versions(tools: &[&str]) -> Vec<(String, String)> {
std::thread::scope(|scope| {
let handles: Vec<_> = tools
.iter()
.map(|&t| scope.spawn(move || (t.to_string(), get_tool_version(t))))
.collect();
handles
.into_iter()
.filter_map(|h| h.join().ok())
.filter_map(|(t, v)| v.map(|v| (t, v)))
.collect()
})
}
/// PRETTY_NAME from /etc/os-release text, without its quotes.
#[cfg(any(not(target_os = "windows"), test))]
fn os_release_pretty_name(content: &str) -> Option<String> {
content
.lines()
.find_map(|line| line.strip_prefix("PRETTY_NAME="))
.map(|name| name.trim_matches('"').to_string())
}
/// Complete system snapshot with all versioning info
fn bi_platform_snapshot(_args: Vec<Value>, input: Option<Value>) -> Result<Value> {
platform_refuse_input("platform_snapshot", &input)?;
let mut snapshot = std::collections::BTreeMap::new();
// Timestamp
snapshot.insert(
"timestamp".to_string(),
Value::Str(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs().to_string())
.unwrap_or_else(|_| "0".to_string()),
),
);
// OS info
snapshot.insert(
"os".to_string(),
Value::Str(std::env::consts::OS.to_string()),
);
snapshot.insert(
"arch".to_string(),
Value::Str(std::env::consts::ARCH.to_string()),
);
snapshot.insert(
"family".to_string(),
Value::Str(std::env::consts::FAMILY.to_string()),
);
// The identity probes. A failed one refuses the whole snapshot: these
// fields feed the fingerprint, so a missing or empty one would change it
// silently. The old code left the field out, or wrote "" for a failed
// `hostname` or `uname`. The hostname is read in-process, as
// `sys_hostname` reads it, since minimal containers have no `hostname`.
let hostname = bi_sys_hostname(Vec::new(), None)?;
snapshot.insert("hostname".to_string(), hostname);
#[cfg(target_os = "windows")]
{
// The same strings as when they came from `cmd /c ver`, so the
// fingerprint is unchanged on an English host.
let version = WindowsVersion::read("platform_snapshot")?;
snapshot.insert("os_version".to_string(), Value::Str(version.ver_line()));
snapshot.insert("build".to_string(), Value::Str(version.build.to_string()));
}
#[cfg(not(target_os = "windows"))]
{
let mut cmd = crate::safety::child_command("uname");
cmd.arg("-r");
let kernel = probe_text("platform_snapshot", "uname", cmd)?;
snapshot.insert("kernel".to_string(), Value::Str(kernel));
let mut cmd = crate::safety::child_command("uname");
cmd.arg("-v");
let build = probe_text("platform_snapshot", "uname", cmd)?;
snapshot.insert("build".to_string(), Value::Str(build));
// Optional by design: not every Unix ships /etc/os-release (macOS does
// not). Then the record omits os_version rather than guessing one.
if let Ok(content) = std::fs::read_to_string("/etc/os-release") {
if let Some(name) = os_release_pretty_name(&content) {
snapshot.insert("os_version".to_string(), Value::Str(name));
}
}
}
// Tool versions
let tools = [
"git",
"python",
"python3",
"node",
"npm",
"cargo",
"rustc",
"go",
"java",
"docker",
"kubectl",
"terraform",
"aws",
"az",
"gcloud",
];
let mut tool_versions = std::collections::BTreeMap::new();
for (tool, ver) in probe_tool_versions(&tools) {
let tool = tool.as_str();
tool_versions.insert(tool.to_string(), Value::Str(ver));
}
snapshot.insert("tools".to_string(), Value::Record(tool_versions));
// Environment variables that affect behavior
let env_vars = [
"PATH",
"HOME",
"USER",
"SHELL",
"TERM",
"LANG",
"LC_ALL",
"CARGO_HOME",
"RUSTUP_HOME",
"GOPATH",
"JAVA_HOME",
"PYTHONPATH",
];
let mut env_map = std::collections::BTreeMap::new();
for var in env_vars {
if let Ok(val) = std::env::var(var) {
env_map.insert(var.to_string(), Value::Str(val));
}
}
snapshot.insert("env".to_string(), Value::Record(env_map));
// The fingerprint names the platform, so it hashes only the host's identity.
// With the timestamp inside it, the same machine gave a different
// fingerprint on every call; the tool probes are time-bounded and one can
// miss under load, which did the same.
let identity: std::collections::BTreeMap<&String, &Value> = snapshot
.iter()
.filter(|(k, _)| {
matches!(
k.as_str(),
"os" | "arch" | "family" | "hostname" | "kernel" | "os_version" | "build"
)
})
.collect();
let fp = format!("{:?}", identity);
let hash = format!("{:x}", md5_simple(&fp));
snapshot.insert("fingerprint".to_string(), Value::Str(hash));
Ok(Value::Record(snapshot))
}
/// Simple MD5-like hash for fingerprinting (not cryptographic)
fn md5_simple(s: &str) -> u64 {
let mut hash: u64 = 0;
for (i, b) in s.bytes().enumerate() {
hash = hash.wrapping_add((b as u64).wrapping_mul((i as u64).wrapping_add(1)));
hash = hash.rotate_left(7);
}
hash
}
/// Each platform's sources are tried in order, and a source that fails hands
/// over to the next. Only when every source has failed is the last failure
/// refused. The old code fell through to a literal "unknown", which read as an
/// answer.
fn bi_platform_os_version(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
#[cfg(target_os = "windows")]
{
// ver's line, as before, from the kernel instead of `cmd /c ver`.
return Ok(Value::Str(
WindowsVersion::read("platform_os_version")?.ver_line(),
));
}
#[allow(unreachable_code)]
let mut failure: Option<anyhow::Error> = None;
if cfg!(target_os = "macos") {
let mut cmd = crate::safety::child_command("sw_vers");
cmd.arg("-productVersion");
match probe_text("platform_os_version", "sw_vers", cmd) {
Ok(text) => return Ok(Value::Str(text)),
Err(e) => failure = Some(e),
}
}
if cfg!(target_os = "linux") {
if let Ok(content) = std::fs::read_to_string("/etc/os-release") {
for line in content.lines() {
if line.starts_with("VERSION=") {
return Ok(Value::Str(
line.trim_start_matches("VERSION=")
.trim_matches('"')
.to_string(),
));
}
}
}
let mut cmd = crate::safety::child_command("uname");
cmd.arg("-r");
match probe_text("platform_os_version", "uname", cmd) {
Ok(text) => return Ok(Value::Str(text)),
Err(e) => failure = Some(e),
}
}
Err(failure.unwrap_or_else(|| {
crate::safety::unimplemented(
"platform_os_version",
"no OS version source on this platform",
"read the OS version from the platform's own tools",
)
}))
}
/// The kernel release. On Windows that is major.minor.build, which is what
/// `wmic os get Version` printed; the update revision is not part of it.
fn bi_platform_kernel(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
#[cfg(target_os = "windows")]
{
return Ok(Value::Str(
WindowsVersion::read("platform_kernel")?.kernel(),
));
}
#[allow(unreachable_code)]
let mut cmd = crate::safety::child_command("uname");
cmd.arg("-r");
Ok(Value::Str(probe_text("platform_kernel", "uname", cmd)?))
}
/// The OS build. On Windows it is the kernel's build number, the number
/// `wmic os get BuildNumber` gave.
fn bi_platform_build(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
#[cfg(target_os = "windows")]
{
return Ok(Value::Str(
WindowsVersion::read("platform_build")?.build.to_string(),
));
}
#[allow(unreachable_code)]
let build = if cfg!(target_os = "macos") {
let mut cmd = crate::safety::child_command("sw_vers");
cmd.arg("-buildVersion");
probe_text("platform_build", "sw_vers", cmd)?
} else if cfg!(target_os = "linux") {
let mut cmd = crate::safety::child_command("uname");
cmd.arg("-v");
probe_text("platform_build", "uname", cmd)?
} else {
return Err(crate::safety::unimplemented(
"platform_build",
"no build source on this platform",
"read the OS build from the platform's own tools",
));
};
Ok(Value::Str(build))
}
fn bi_platform_arch(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
Ok(Value::Str(std::env::consts::ARCH.to_string()))
}
fn bi_platform_hostname(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
// Its own copy ran `hostname` and answered the string "unknown" when that
// failed. One implementation, which reports a failure as one.
bi_sys_hostname(args, input)
}
/// The machine's identifier. A source that cannot answer is refused: an empty
/// or missing identifier used to come back as "unknown", a value that looks
/// like one. Windows reads the system UUID through PowerShell, as
/// `bi_sys_uptime` does, because `wmic` is absent from current Windows 11 installs.
fn bi_platform_machine_id(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
if cfg!(target_os = "linux") {
let id = std::fs::read_to_string("/etc/machine-id")
.map_err(|e| crate::safety::fs_error("platform_machine_id", "/etc/machine-id", &e))?;
return non_empty_machine_id(id.trim().to_string());
}
if cfg!(target_os = "macos") {
let mut cmd = crate::safety::child_command("ioreg");
cmd.args(["-rd1", "-c", "IOPlatformExpertDevice"]);
let text = probe_text("platform_machine_id", "ioreg", cmd)?;
let uuid = text
.lines()
.find(|line| line.contains("IOPlatformUUID"))
.and_then(|line| line.split('"').nth(3))
.unwrap_or("");
return non_empty_machine_id(uuid.to_string());
}
if cfg!(target_os = "windows") {
let mut cmd = crate::safety::child_command("powershell");
cmd.args([
"-NoProfile",
"-Command",
"(Get-CimInstance Win32_ComputerSystemProduct).UUID",
]);
let uuid = probe_text("platform_machine_id", "powershell", cmd)?;
return non_empty_machine_id(uuid);
}
Err(crate::safety::unimplemented(
"platform_machine_id",
"no machine identifier source on this platform",
"read the machine identifier from the platform's own tools",
))
}
fn non_empty_machine_id(id: String) -> Result<Value> {
if id.is_empty() {
return Err(crate::safety::bad_state(
"platform_machine_id",
"the platform reported no machine identifier",
"a host with a machine identifier (/etc/machine-id, IOPlatformUUID or the system UUID)",
));
}
Ok(Value::Str(id))
}
#[cfg(all(test, any(unix, windows)))]
mod probe_status_tests {
use super::*;
/// An `Output` with exit `code`, built the way the at_queue tests build one.
fn output(code: i32, stdout: &str, stderr: &str) -> std::process::Output {
#[cfg(unix)]
let status = {
use std::os::unix::process::ExitStatusExt;
std::process::ExitStatus::from_raw(code << 8)
};
#[cfg(windows)]
let status = {
use std::os::windows::process::ExitStatusExt;
std::process::ExitStatus::from_raw(code as u32)
};
std::process::Output {
status,
stdout: stdout.as_bytes().to_vec(),
stderr: stderr.as_bytes().to_vec(),
}
}
#[test]
fn a_failed_probe_is_refused_with_its_code_even_when_it_printed_something() {
let err = probe_stdout("sys_kernel", "uname", output(3, "5.15.0\n", "uname: boom"))
.unwrap_err()
.to_string();
assert!(err.contains("E_TOOL_FAILED"), "{err}");
assert!(err.contains("exited 3"), "{err}");
assert!(err.contains("uname: boom"), "{err}");
}
#[test]
fn a_failed_probe_with_empty_stdout_is_not_an_empty_answer() {
let err = probe_stdout("platform_kernel", "uname", output(1, "", ""))
.unwrap_err()
.to_string();
assert!(err.contains("E_TOOL_FAILED"), "{err}");
}
#[test]
fn a_successful_probe_returns_its_trimmed_stdout() {
let text = probe_stdout(
"sys_kernel",
"uname",
output(0, " 6.8.0-45-generic \n", ""),
)
.unwrap();
assert_eq!(text, "6.8.0-45-generic");
}
#[test]
fn a_session_listing_is_its_sessions() {
let Ok(Value::Array(rows)) = screen_sessions(
true,
Some(0),
"There is a screen on:\n\t4242.work\t(04/01/2026 10:00:00 AM)\t(Detached)\n1 Socket in /run/screen/S-u.\n",
"",
) else {
panic!("expected an array of sessions")
};
assert_eq!(rows.len(), 1);
let Value::Record(row) = &rows[0] else {
panic!("expected a record")
};
assert_eq!(row.get("id"), Some(&Value::Str("4242.work".into())));
}
#[test]
fn no_sockets_is_an_empty_list_whichever_stream_says_it() {
for (stdout, stderr) in [
("No Sockets found in /run/screen/S-u.\n", ""),
("", "No Sockets found in /run/screen/S-u.\n"),
] {
let Ok(Value::Array(rows)) = screen_sessions(false, Some(1), stdout, stderr) else {
panic!("'no sockets' should be an empty array")
};
assert!(rows.is_empty());
}
}
#[test]
fn a_listing_of_only_dead_sessions_is_still_the_answer() {
let Ok(Value::Array(rows)) = screen_sessions(
false,
Some(10),
"\t77.old\t(04/01/2026 09:00:00 AM)\t(Dead ???)\n",
"",
) else {
panic!("dead sessions are an answer, not a failure")
};
assert_eq!(rows.len(), 1);
}
#[test]
fn a_failed_listing_with_no_sessions_is_refused_not_reported_as_none() {
let err = match screen_sessions(
false,
Some(1),
"",
"screen: cannot open /run/screen/S-u: Permission denied",
) {
Err(e) => e.to_string(),
Ok(v) => panic!("a failed listing returned {v:?}"),
};
assert!(err.contains("E_TOOL_FAILED"), "{err}");
assert!(err.contains("exited 1"), "{err}");
assert!(err.contains("Permission denied"), "{err}");
}
}
fn bi_platform_tool_version(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// A missing or non-string argument used to query a tool literally named
// "unknown". It is refused, as the sibling platform_* builtins refuse theirs.
let tool = match args.first() {
Some(v) => v.as_str().map_err(|_| {
crate::safety::bad_arg(
"platform_tool_version",
"a tool name: String",
v.type_name(),
)
})?,
None => {
return Err(crate::safety::bad_arg(
"platform_tool_version",
"a tool name: String",
"nothing",
))
}
};
match get_tool_version(tool) {
Some(ver) => Ok(Value::Str(ver)),
None => Ok(Value::Null),
}
}
fn bi_platform_tool_versions(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut versions = std::collections::BTreeMap::new();
// Every group is collected first and probed in ONE parallel batch.
// Probing the seventeen groups one after another made this cost the sum
// of seventeen slowest-tools: 108s under WSL, and still over ten seconds
// on the CI runner, where the probe gate killed it as hung.
let mut pending: Vec<(&str, &[&str])> = Vec::new();
// Compilers & Build Tools
let compilers = [
"gcc", "g++", "clang", "clang++", "rustc", "go", "javac", "swift", "kotlinc", "scalac",
"ghc", "ocaml", "fpc", "gfortran", "nvcc", "hipcc", "dmd", "ldc2", "gdc", "zig", "nim",
"crystal",
];
pending.push(("compiler", &compilers[..]));
// Build Systems
let build_systems = [
"make", "cmake", "ninja", "meson", "bazel", "buck2", "scons", "gradle", "maven", "ant",
"cargo", "cabal", "stack", "mix", "dune", "zig", "xmake", "premake5", "waf",
];
pending.push(("build", &build_systems[..]));
// Linkers
let linkers = ["ld", "lld", "gold", "mold", "link"];
pending.push(("linker", &linkers[..]));
// Language Runtimes
let runtimes = [
"python", "python3", "node", "deno", "bun", "ruby", "php", "perl", "lua", "luajit",
"julia", "R", "java", "dotnet", "mono", "erlang", "elixir", "racket", "sbcl", "clisp",
];
pending.push(("runtime", &runtimes[..]));
// Package Managers (Language)
let pkg_lang = [
"pip", "pip3", "pipx", "poetry", "pdm", "uv", "conda", "mamba", "npm", "yarn", "pnpm",
"bun", "gem", "bundler", "composer", "cargo", "go", "nuget", "dotnet", "maven", "gradle",
"cabal", "stack", "opam", "mix", "hex", "rebar3", "cpan", "luarocks",
];
pending.push(("pkg", &pkg_lang[..]));
// Package Managers (System)
let pkg_sys = [
"apt", "apt-get", "dnf", "yum", "pacman", "zypper", "apk", "brew", "port", "nix", "guix",
"snap", "flatpak", "choco", "winget", "scoop", "vcpkg", "conan",
];
pending.push(("syspkg", &pkg_sys[..]));
// Version Control
let vcs = ["git", "hg", "svn", "fossil", "darcs", "bzr", "cvs"];
pending.push(("vcs", &vcs[..]));
// Containers & Virtualization
let containers = [
"docker",
"podman",
"buildah",
"skopeo",
"containerd",
"runc",
"nerdctl",
"crictl",
"ctr",
"lxc",
"lxd",
"vagrant",
"packer",
"qemu-system-x86_64",
"vboxmanage",
"multipass",
];
pending.push(("container", &containers[..]));
// Orchestration & IaC
let orchestration = [
"kubectl",
"helm",
"kustomize",
"k3s",
"k3d",
"kind",
"minikube",
"terraform",
"terragrunt",
"pulumi",
"cdktf",
"crossplane",
"ansible",
"ansible-playbook",
"chef",
"puppet",
"salt",
];
pending.push(("iac", &orchestration[..]));
// Cloud CLIs
let cloud = [
"aws",
"az",
"gcloud",
"doctl",
"linode-cli",
"vultr-cli",
"oci",
"ibmcloud",
"hcloud",
"exo",
"scaleway",
"flyctl",
];
pending.push(("cloud", &cloud[..]));
// Databases
let databases = [
"psql",
"mysql",
"sqlite3",
"mongosh",
"redis-cli",
"mongod",
"pg_dump",
"mysqldump",
"cqlsh",
"influx",
"clickhouse-client",
];
pending.push(("db", &databases[..]));
// Testing & Quality
let testing = [
"pytest", "jest", "mocha", "rspec", "phpunit", "go", "cargo", "valgrind", "gdb", "lldb",
"strace", "dtrace", "perf",
];
pending.push(("test", &testing[..]));
// Linters & Formatters
let linters = [
"eslint",
"prettier",
"black",
"ruff",
"mypy",
"pylint",
"flake8",
"rubocop",
"clippy",
"rustfmt",
"gofmt",
"golangci-lint",
"shellcheck",
"hadolint",
"yamllint",
"jsonlint",
"tflint",
];
pending.push(("lint", &linters[..]));
// Documentation
let docs = [
"sphinx-build",
"mkdocs",
"hugo",
"jekyll",
"doxygen",
"typedoc",
"rustdoc",
"godoc",
"javadoc",
"pdoc",
];
pending.push(("docs", &docs[..]));
// CI/CD & DevOps
let cicd = [
"gh",
"gitlab-runner",
"circleci",
"drone",
"act",
"jenkins-cli",
"argocd",
"flux",
"tekton",
"skaffold",
"tilt",
"devspace",
];
pending.push(("cicd", &cicd[..]));
// Security
let security = [
"gpg",
"openssl",
"ssh",
"ssh-keygen",
"age",
"sops",
"vault",
"trivy",
"grype",
"syft",
"cosign",
"sigstore",
];
pending.push(("security", &security[..]));
// Network Tools
let network = [
"curl",
"wget",
"httpie",
"jq",
"yq",
"fx",
"grpcurl",
"nmap",
"netcat",
"socat",
"tcpdump",
"wireshark",
];
pending.push(("net", &network[..]));
let mut all: Vec<&str> = pending
.iter()
.flat_map(|(_, g)| g.iter().copied())
.collect();
all.sort_unstable();
all.dedup();
let found: std::collections::HashMap<String, String> =
probe_tool_versions(&all).into_iter().collect();
for (prefix, group) in &pending {
for tool in group.iter() {
if let Some(ver) = found.get(*tool) {
versions.insert(format!("{prefix}.{tool}"), Value::Str(ver.clone()));
}
}
}
Ok(Value::Record(versions))
}
fn bi_platform_detect_tools(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let tools = [
"git",
"python",
"python3",
"node",
"npm",
"yarn",
"pnpm",
"cargo",
"rustc",
"go",
"java",
"javac",
"docker",
"podman",
"kubectl",
"helm",
"terraform",
"ansible",
"aws",
"az",
"gcloud",
"ruby",
"gem",
"php",
"composer",
"dotnet",
"swift",
"gcc",
"g++",
"clang",
"make",
"cmake",
"ninja",
"meson",
"gradle",
"maven",
"pip",
"pip3",
"pipenv",
"poetry",
"conda",
"mamba",
"uv",
"nvcc",
"sqlite3",
"psql",
"mysql",
"mongod",
"redis-cli",
];
let available: Vec<Value> = tools
.iter()
.filter(|t| on_path(t))
.map(|t| Value::Str(t.to_string()))
.collect();
Ok(Value::Array(available))
}
fn bi_platform_has_tool(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// With no tool named this asked whether "" is installed, and said no.
let tool = match args.first() {
Some(v) => expect_string("platform_has_tool", v)?,
None => {
return Err(crate::safety::bad_arg(
"platform_has_tool",
"a tool name",
"nothing",
))
}
};
Ok(Value::Bool(on_path(tool)))
}
/// Whether this Windows process is elevated, read from its own token.
///
/// This was `net session` here and PowerShell's
/// `IsInRole(Administrator)` in sudo_check. Both answer the same question as
/// the token's elevation flag (under UAC an administrator's unelevated
/// process is not elevated for any of them), but each started a process, and
/// `net session` also failed, reading as "not admin", when the Server service
/// was stopped.
#[cfg(target_os = "windows")]
pub(crate) fn windows_is_admin(builtin: &str) -> Result<bool> {
use std::ffi::c_void;
#[link(name = "kernel32")]
extern "system" {
fn GetCurrentProcess() -> *mut c_void;
fn CloseHandle(handle: *mut c_void) -> i32;
}
#[link(name = "advapi32")]
extern "system" {
fn OpenProcessToken(process: *mut c_void, access: u32, token: *mut *mut c_void) -> i32;
fn GetTokenInformation(
token: *mut c_void,
class: u32,
info: *mut c_void,
len: u32,
returned: *mut u32,
) -> i32;
}
const TOKEN_QUERY: u32 = 0x0008;
const TOKEN_ELEVATION: u32 = 20;
let failed = |call: &str| {
crate::safety::bad_state(
builtin,
&format!("{call} failed: {}", std::io::Error::last_os_error()),
"a process token this process can query",
)
};
let mut token: *mut c_void = std::ptr::null_mut();
// SAFETY: GetCurrentProcess returns a pseudo-handle that needs no close;
// `token` is a valid out-pointer.
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 {
return Err(failed("OpenProcessToken"));
}
let mut elevated: u32 = 0;
let mut returned: u32 = 0;
// SAFETY: TOKEN_ELEVATION is a single DWORD, and `elevated` is one.
let ok = unsafe {
GetTokenInformation(
token,
TOKEN_ELEVATION,
(&mut elevated as *mut u32).cast(),
std::mem::size_of::<u32>() as u32,
&mut returned,
)
};
let result = if ok == 0 {
Err(failed("GetTokenInformation"))
} else {
Ok(elevated != 0)
};
// SAFETY: `token` was opened above and is closed once.
unsafe { CloseHandle(token) };
result
}
/// The drive types Get-PSDrive -PSProvider FileSystem listed in
/// `bi_fs_df` and `bi_fs_mounts`: fixed, removable, network and RAM-disk
/// roots. CD-ROM drives are not in that set.
#[cfg(target_os = "windows")]
fn windows_listed_by_powershell(drive_type: u32) -> bool {
matches!(
drive_type,
DRIVE_FIXED | DRIVE_REMOVABLE | DRIVE_REMOTE | DRIVE_RAMDISK
)
}
#[cfg(target_os = "windows")]
const DRIVE_UNKNOWN: u32 = 0;
#[cfg(target_os = "windows")]
const DRIVE_NO_ROOT_DIR: u32 = 1;
#[cfg(target_os = "windows")]
const DRIVE_REMOVABLE: u32 = 2;
#[cfg(target_os = "windows")]
const DRIVE_FIXED: u32 = 3;
#[cfg(target_os = "windows")]
const DRIVE_REMOTE: u32 = 4;
#[cfg(target_os = "windows")]
const DRIVE_RAMDISK: u32 = 6;
/// One drive letter with a root, as kernel32 described it. A field is `None`
/// where the call that answers it failed: an empty card reader or a
/// disconnected network drive has no size and no file system.
#[cfg(target_os = "windows")]
struct WinVolume {
/// The drive letter, such as 'C'.
letter: char,
/// GetDriveTypeW's answer, one of the DRIVE_* constants.
drive_type: u32,
/// Total bytes on the volume, from GetDiskFreeSpaceExW.
total: Option<u64>,
/// Free bytes, from GetDiskFreeSpaceExW's TotalNumberOfFreeBytes. This is
/// the figure WMI's FreeSpace and Get-PSDrive's Free reported, not the
/// caller-available figure.
free: Option<u64>,
/// The file system name from GetVolumeInformationW, such as "NTFS".
fs_name: Option<String>,
/// The volume label. `Some("")` is an unlabeled volume that answered.
label: Option<String>,
}
/// [`windows_volumes_now`] on a worker thread, bounded by the probe deadline.
///
/// PowerShell's Get-PSDrive was bounded by it, and these calls need it too:
/// asking a disconnected network drive for its size waits on SMB, which can
/// take longer than that. The worker also turns off the "There is no disk in
/// the drive" dialog an empty removable drive could raise on the desktop.
#[cfg(target_os = "windows")]
fn windows_volumes(builtin: &str) -> Result<Vec<WinVolume>> {
#[link(name = "kernel32")]
extern "system" {
fn SetThreadErrorMode(mode: u32, old: *mut u32) -> i32;
}
const SEM_FAILCRITICALERRORS: u32 = 0x0001;
const SEM_NOOPENFILEERRORBOX: u32 = 0x8000;
let (tx, rx) = std::sync::mpsc::channel();
let name = builtin.to_string();
std::thread::spawn(move || {
// SAFETY: affects only this worker thread; the old mode is not needed
// because the thread ends after the walk.
unsafe {
SetThreadErrorMode(
SEM_FAILCRITICALERRORS | SEM_NOOPENFILEERRORBOX,
std::ptr::null_mut(),
)
};
let _ = tx.send(windows_volumes_now(&name));
});
match rx.recv_timeout(std::time::Duration::from_secs(PROBE_DEADLINE_SECS)) {
Ok(result) => result,
Err(_) => Err(crate::safety::budget_exceeded(
builtin,
format!("the drive table did not answer in {PROBE_DEADLINE_SECS}s"),
"a network drive may be unreachable; disconnect it or retry",
)),
}
}
/// Every drive letter with a root (GetLogicalDrives, GetDriveTypeW). A zero
/// bitmask is a failed call, since no machine has no drive letters.
#[cfg(target_os = "windows")]
fn windows_volumes_now(builtin: &str) -> Result<Vec<WinVolume>> {
#[link(name = "kernel32")]
extern "system" {
fn GetLogicalDrives() -> u32;
fn GetDriveTypeW(root: *const u16) -> u32;
fn GetDiskFreeSpaceExW(
root: *const u16,
free_to_caller: *mut u64,
total: *mut u64,
total_free: *mut u64,
) -> i32;
fn GetVolumeInformationW(
root: *const u16,
name: *mut u16,
name_len: u32,
serial: *mut u32,
max_component: *mut u32,
flags: *mut u32,
fs_name: *mut u16,
fs_name_len: u32,
) -> i32;
}
// MAX_PATH + 1, the buffer size GetVolumeInformationW is documented with.
const NAME_UNITS: usize = 261;
let text = |buf: &[u16]| {
let end = buf.iter().position(|&u| u == 0).unwrap_or(buf.len());
String::from_utf16_lossy(&buf[..end])
};
// SAFETY: GetLogicalDrives takes no arguments and only reads the drive
// table.
let mask = unsafe { GetLogicalDrives() };
if mask == 0 {
return Err(crate::safety::bad_state(
builtin,
&format!(
"GetLogicalDrives failed: {}",
std::io::Error::last_os_error()
),
"a readable drive table",
));
}
let mut volumes = Vec::new();
for bit in 0..26u32 {
if mask & (1 << bit) == 0 {
continue;
}
let letter = char::from(b'A' + bit as u8);
let root: Vec<u16> = format!("{letter}:\\")
.encode_utf16()
.chain(std::iter::once(0))
.collect();
// SAFETY: `root` is NUL-terminated and lives across the call.
let drive_type = unsafe { GetDriveTypeW(root.as_ptr()) };
if drive_type == DRIVE_UNKNOWN || drive_type == DRIVE_NO_ROOT_DIR {
continue;
}
let (mut free_to_caller, mut total, mut total_free) = (0u64, 0u64, 0u64);
// SAFETY: `root` is NUL-terminated, and each out-pointer is a valid
// u64 for the call to write.
let sized = unsafe {
GetDiskFreeSpaceExW(
root.as_ptr(),
&mut free_to_caller,
&mut total,
&mut total_free,
)
} != 0;
// An empty or disconnected drive fails here, and its size stays unknown.
let (size, free) = if sized {
(Some(total), Some(total_free))
} else {
(None, None)
};
let mut label_buf = [0u16; NAME_UNITS];
let mut fs_buf = [0u16; NAME_UNITS];
// SAFETY: both buffers hold NAME_UNITS units, and the lengths passed
// say so. The serial, max-component and flags out-pointers are null,
// which the call accepts.
let described = unsafe {
GetVolumeInformationW(
root.as_ptr(),
label_buf.as_mut_ptr(),
NAME_UNITS as u32,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
fs_buf.as_mut_ptr(),
NAME_UNITS as u32,
)
} != 0;
let (label, fs_name) = if described {
(Some(text(&label_buf)), Some(text(&fs_buf)))
} else {
(None, None)
};
volumes.push(WinVolume {
letter,
drive_type,
total: size,
free,
fs_name,
label,
});
}
Ok(volumes)
}
/// A REG_DWORD under HKEY_LOCAL_MACHINE: `Ok(None)` when the key or value is
/// absent, `Err` with the Win32 status for anything else.
#[cfg(target_os = "windows")]
fn windows_hklm_dword(subkey: &str, value: &str) -> std::result::Result<Option<u32>, i32> {
use std::ffi::c_void;
#[link(name = "advapi32")]
extern "system" {
fn RegGetValueW(
key: isize,
subkey: *const u16,
value: *const u16,
flags: u32,
kind: *mut u32,
data: *mut c_void,
len: *mut u32,
) -> i32;
}
const HKEY_LOCAL_MACHINE: isize = 0x8000_0002u32 as i32 as isize;
const RRF_RT_REG_DWORD: u32 = 0x0000_0010;
const ERROR_FILE_NOT_FOUND: i32 = 2;
let wide = |s: &str| {
s.encode_utf16()
.chain(std::iter::once(0))
.collect::<Vec<u16>>()
};
let (subkey, value) = (wide(subkey), wide(value));
let mut data: u32 = 0;
let mut len = std::mem::size_of::<u32>() as u32;
// SAFETY: both names are NUL-terminated, and RRF_RT_REG_DWORD writes at
// most `len` (4) bytes into `data`.
let status = unsafe {
RegGetValueW(
HKEY_LOCAL_MACHINE,
subkey.as_ptr(),
value.as_ptr(),
RRF_RT_REG_DWORD,
std::ptr::null_mut(),
(&mut data as *mut u32).cast(),
&mut len,
)
};
match status {
0 => Ok(Some(data)),
ERROR_FILE_NOT_FOUND => Ok(None),
other => Err(other),
}
}
/// A REG_SZ under HKEY_LOCAL_MACHINE, as [`windows_hklm_dword`] reads a
/// DWORD.
#[cfg(target_os = "windows")]
fn windows_hklm_string(subkey: &str, value: &str) -> std::result::Result<Option<String>, i32> {
windows_hklm_string_from(subkey, value, 128)
}
/// [`windows_hklm_string`] starting from a buffer of `units` UTF-16 units,
/// so a test can make it grow.
#[cfg(target_os = "windows")]
fn windows_hklm_string_from(
subkey: &str,
value: &str,
units: usize,
) -> std::result::Result<Option<String>, i32> {
use std::ffi::c_void;
#[link(name = "advapi32")]
extern "system" {
fn RegGetValueW(
key: isize,
subkey: *const u16,
value: *const u16,
flags: u32,
kind: *mut u32,
data: *mut c_void,
len: *mut u32,
) -> i32;
}
const HKEY_LOCAL_MACHINE: isize = 0x8000_0002u32 as i32 as isize;
const RRF_RT_REG_SZ: u32 = 0x0000_0002;
const ERROR_FILE_NOT_FOUND: i32 = 2;
let wide = |s: &str| {
s.encode_utf16()
.chain(std::iter::once(0))
.collect::<Vec<u16>>()
};
let (subkey, value) = (wide(subkey), wide(value));
let mut buf = vec![0u16; units.max(1)];
loop {
let mut len = (buf.len() * 2) as u32;
// SAFETY: both names are NUL-terminated, and RegGetValueW writes at
// most `len` bytes into `buf`, NUL included.
let status = unsafe {
RegGetValueW(
HKEY_LOCAL_MACHINE,
subkey.as_ptr(),
value.as_ptr(),
RRF_RT_REG_SZ,
std::ptr::null_mut(),
buf.as_mut_ptr().cast(),
&mut len,
)
};
match status {
0 => {
let units = (len as usize / 2).min(buf.len());
let text = &buf[..units];
let end = text.iter().position(|&u| u == 0).unwrap_or(text.len());
return Ok(Some(String::from_utf16_lossy(&text[..end])));
}
ERROR_FILE_NOT_FOUND => return Ok(None),
// ERROR_MORE_DATA: `len` is the size needed.
234 if (len as usize) / 2 > buf.len() => buf = vec![0u16; len as usize / 2 + 1],
other => return Err(other),
}
}
}
/// Processor 0's clock in MHz as the kernel measured it at boot, from the
/// registry, without starting PowerShell to ask WMI. It is not quite WMI's
/// MaxClockSpeed, the rated figure: on one host 4392 where WMI said 4400.
#[cfg(target_os = "windows")]
fn windows_cpu_mhz(builtin: &str) -> Result<Option<u32>> {
windows_hklm_dword(r"HARDWARE\DESCRIPTION\System\CentralProcessor\0", "~MHz").map_err(
|status| {
crate::safety::bad_state(
builtin,
&format!(
"the processor's ~MHz registry value could not be read: {}",
std::io::Error::from_raw_os_error(status)
),
"a readable HKLM\\HARDWARE\\DESCRIPTION\\System\\CentralProcessor\\0",
)
},
)
}
fn bi_platform_capabilities(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut caps = std::collections::BTreeMap::new();
// Admin/sudo
#[cfg(target_os = "windows")]
{
// Check if running as admin
let is_admin = windows_is_admin("platform_capabilities")?;
caps.insert("has_admin".to_string(), Value::Bool(is_admin));
caps.insert("has_sudo".to_string(), Value::Bool(false));
}
#[cfg(not(target_os = "windows"))]
{
caps.insert(
"has_admin".to_string(),
Value::Bool(unsafe { libc::geteuid() } == 0),
);
caps.insert("has_sudo".to_string(), Value::Bool(on_path("sudo")));
}
// GUI
#[cfg(target_os = "windows")]
{
caps.insert("has_gui".to_string(), Value::Bool(true));
}
#[cfg(target_os = "macos")]
{
caps.insert("has_gui".to_string(), Value::Bool(true));
}
#[cfg(target_os = "linux")]
{
let has_display =
std::env::var("DISPLAY").is_ok() || std::env::var("WAYLAND_DISPLAY").is_ok();
caps.insert("has_gui".to_string(), Value::Bool(has_display));
}
// Network: a routable interface address. This was a hard-coded true.
caps.insert("has_network".to_string(), Value::Bool(host_has_network()));
// Container runtimes
caps.insert("has_docker".to_string(), Value::Bool(on_path("docker")));
caps.insert("has_podman".to_string(), Value::Bool(on_path("podman")));
caps.insert(
"has_container".to_string(),
Value::Bool(on_path("docker") || on_path("podman")),
);
// Package managers
let mut pkg_mgrs = Vec::new();
if on_path("apt") {
pkg_mgrs.push("apt");
}
if on_path("dnf") {
pkg_mgrs.push("dnf");
}
if on_path("yum") {
pkg_mgrs.push("yum");
}
if on_path("pacman") {
pkg_mgrs.push("pacman");
}
if on_path("brew") {
pkg_mgrs.push("brew");
}
if on_path("choco") {
pkg_mgrs.push("choco");
}
if on_path("winget") {
pkg_mgrs.push("winget");
}
if on_path("scoop") {
pkg_mgrs.push("scoop");
}
if on_path("snap") {
pkg_mgrs.push("snap");
}
if on_path("flatpak") {
pkg_mgrs.push("flatpak");
}
caps.insert(
"pkg_managers".to_string(),
Value::Array(
pkg_mgrs
.into_iter()
.map(|s| Value::Str(s.to_string()))
.collect(),
),
);
Ok(Value::Record(caps))
}
fn bi_platform_has_admin(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
#[cfg(target_os = "windows")]
{
Ok(Value::Bool(windows_is_admin("platform_has_admin")?))
}
#[cfg(not(target_os = "windows"))]
{
return Ok(Value::Bool(unsafe { libc::geteuid() } == 0));
}
}
fn bi_platform_has_sudo(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
Ok(Value::Bool(on_path("sudo")))
}
fn bi_platform_has_gui(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
#[cfg(target_os = "windows")]
{
Ok(Value::Bool(true))
}
#[cfg(target_os = "macos")]
{
return Ok(Value::Bool(true));
}
#[cfg(target_os = "linux")]
{
let has_display =
std::env::var("DISPLAY").is_ok() || std::env::var("WAYLAND_DISPLAY").is_ok();
return Ok(Value::Bool(has_display));
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
{
return Ok(Value::Bool(false));
}
}
/// Whether any address in `addrs` is one a peer could reach: not loopback and
/// not an IPv6 link-local (fe80::/10) address, which every IPv6 interface has
/// even when it has no route.
fn any_routable(addrs: &[std::net::IpAddr]) -> bool {
addrs.iter().any(|addr| {
!addr.is_loopback()
&& !matches!(addr, std::net::IpAddr::V6(v6) if (v6.segments()[0] & 0xffc0) == 0xfe80)
})
}
/// Whether this host has a network interface with a routable address. The old
/// check bound 127.0.0.1:0, which succeeds offline, so it said yes everywhere.
fn host_has_network() -> bool {
let networks = sysinfo::Networks::new_with_refreshed_list();
let mut addrs = Vec::new();
for (_, data) in &networks {
for net in data.ip_networks() {
addrs.push(net.addr);
}
}
any_routable(&addrs)
}
fn bi_platform_has_network(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
Ok(Value::Bool(host_has_network()))
}
#[cfg(test)]
mod quiet_platform_tests {
use super::*;
use std::net::IpAddr;
fn ip(text: &str) -> IpAddr {
text.parse().unwrap()
}
#[test]
fn loopback_only_is_not_routable() {
assert!(!any_routable(&[ip("127.0.0.1"), ip("::1")]));
}
#[test]
fn no_addresses_is_not_routable() {
assert!(!any_routable(&[]));
}
#[test]
fn ipv6_link_local_is_not_routable() {
assert!(!any_routable(&[
ip("127.0.0.1"),
ip("::1"),
ip("fe80::1"),
ip("febf::2"),
]));
}
#[test]
fn lan_address_is_routable() {
assert!(any_routable(&[ip("127.0.0.1"), ip("192.168.1.5")]));
}
#[test]
fn global_ipv6_is_routable() {
assert!(any_routable(&[ip("::1"), ip("2001:db8::1")]));
}
#[test]
fn site_local_ipv6_is_routable() {
// fec0::/10 is not link-local (fe80::/10), so it counts.
assert!(any_routable(&[ip("fec0::1")]));
}
}
fn bi_platform_has_container(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
Ok(Value::Bool(on_path("docker") || on_path("podman")))
}
fn bi_platform_pkg_managers(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let managers = [
("apt", "apt"),
("apt-get", "apt-get"),
("dnf", "dnf"),
("yum", "yum"),
("pacman", "pacman"),
("zypper", "zypper"),
("brew", "brew"),
("port", "port"),
("choco", "choco"),
("winget", "winget"),
("scoop", "scoop"),
("snap", "snap"),
("flatpak", "flatpak"),
("nix", "nix"),
("guix", "guix"),
];
let available: Vec<Value> = managers
.iter()
.filter(|(_, cmd)| on_path(cmd))
.map(|(name, _)| Value::Str(name.to_string()))
.collect();
Ok(Value::Array(available))
}
fn bi_platform_normalize_path(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let path = args.first().and_then(|v| v.as_str().ok()).unwrap_or("");
// Convert backslashes to forward slashes for cross-platform consistency
let normalized = path.replace('\\', "/");
Ok(Value::Str(normalized))
}
fn bi_platform_normalize_output(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let output = args.first().and_then(|v| v.as_str().ok()).unwrap_or("");
// Normalize line endings to LF
let normalized = output.replace("\r\n", "\n").replace('\r', "\n");
Ok(Value::Str(normalized))
}
fn bi_platform_path_sep(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
Ok(Value::Str(std::path::MAIN_SEPARATOR.to_string()))
}
fn bi_platform_line_ending(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
#[cfg(target_os = "windows")]
{
Ok(Value::Str("\r\n".to_string()))
}
#[cfg(not(target_os = "windows"))]
{
return Ok(Value::Str("\n".to_string()));
}
}
fn bi_platform_shell_type(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
#[cfg(target_os = "windows")]
{
if std::env::var("PSModulePath").is_ok() {
return Ok(Value::Str("powershell".to_string()));
}
Ok(Value::Str("cmd".to_string()))
}
#[cfg(not(target_os = "windows"))]
{
if let Ok(shell) = std::env::var("SHELL") {
let shell_name = std::path::Path::new(&shell)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("sh");
return Ok(Value::Str(shell_name.to_string()));
}
return Ok(Value::Str("sh".to_string()));
}
}
/// The first dotted number in `s` ("Python 3.12.1" -> [3, 12, 1]).
fn first_version(s: &str) -> Option<Vec<u64>> {
static RE: std::sync::LazyLock<regex::Regex> =
std::sync::LazyLock::new(|| regex::Regex::new(r"\d+(?:\.\d+)*").unwrap());
let m = RE.find(s)?;
m.as_str().split('.').map(|p| p.parse().ok()).collect()
}
/// `">=3.10"` -> (">=", [3, 10]). A bare version means `>=`, which is what a
/// requirement written as `{python: "3.10"}` asks for.
fn parse_version_req(req: &str) -> Option<(&'static str, Vec<u64>)> {
let req = req.trim();
let (op, rest) = [">=", "<=", "==", ">", "<", "="]
.iter()
.find_map(|op| req.strip_prefix(op).map(|r| (*op, r)))
.unwrap_or((">=", req));
let op = if op == "=" { "==" } else { op };
let rest = rest.trim();
if rest.is_empty() || !rest.chars().all(|c| c.is_ascii_digit() || c == '.') {
return None;
}
Some((op, first_version(rest)?))
}
fn version_satisfies(have: &[u64], op: &str, want: &[u64]) -> bool {
// Compare over the requirement's precision: "3.10" is met by 3.10.4.
let n = want.len();
let have: Vec<u64> = (0..n).map(|i| have.get(i).copied().unwrap_or(0)).collect();
let ord = have.as_slice().cmp(want);
match op {
">=" => ord.is_ge(),
"<=" => ord.is_le(),
">" => ord.is_gt(),
"<" => ord.is_lt(),
_ => ord.is_eq(),
}
}
fn bi_platform_require(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
platform_refuse_input("platform_require", &input)?;
// Expect a record like {os: "linux|windows", python: ">=3.10", docker: true}
//
// Four answers here were made without looking. A non-record argument was
// "satisfied"; `os: 42` compared the OS against ""; a tool value that was
// neither Bool nor String was skipped; and a version requirement was a
// TODO, so {python: ">=3.10"} held whenever any python was installed.
let spec =
match args.first() {
Some(Value::Record(r)) => r.clone(),
None => {
// No requirements: vacuously satisfied.
return Ok(Value::Record({
let mut m = std::collections::BTreeMap::new();
m.insert("satisfied".to_string(), Value::Bool(true));
m.insert("missing".to_string(), Value::Array(vec![]));
m
}));
}
Some(other) => return Err(crate::safety::bad_arg(
"platform_require",
"a record of requirements, e.g. {os: \"linux\", python: \">=3.10\", docker: true}",
other.type_name(),
)),
};
let mut missing = Vec::new();
for (key, val) in &spec {
match key.as_str() {
"os" | "arch" => {
let Value::Str(required) = val else {
return Err(crate::safety::bad_arg(
"platform_require",
&format!("{key} as a string such as \"linux|macos\""),
val.type_name(),
));
};
let current = if key == "os" {
std::env::consts::OS
} else {
std::env::consts::ARCH
};
if !required.split('|').any(|o| o.trim() == current) {
missing.push(Value::Str(format!(
"{key}: need {required}, have {current}"
)));
}
}
_ => match val {
Value::Bool(false) => {}
Value::Bool(true) => {
if !on_path(key) {
missing.push(Value::Str(format!("{key}: not found")));
}
}
Value::Str(req) => {
let Some((op, want)) = parse_version_req(req) else {
return Err(crate::safety::bad_arg(
"platform_require",
&format!("{key} as a version requirement such as \">=3.10\""),
&format!("\"{req}\""),
));
};
if !on_path(key) {
missing.push(Value::Str(format!("{key}: not found")));
continue;
}
match get_tool_version(key).as_deref().and_then(first_version) {
Some(have) if version_satisfies(&have, op, &want) => {}
Some(have) => missing.push(Value::Str(format!(
"{key}: need {req}, have {}",
have.iter()
.map(u64::to_string)
.collect::<Vec<_>>()
.join(".")
))),
// Installed, but it would not say which version. Not
// provably satisfied, so not reported as satisfied.
None => missing.push(Value::Str(format!(
"{key}: need {req}, version could not be determined"
))),
}
}
other => {
return Err(crate::safety::bad_arg(
"platform_require",
&format!("{key} as true/false or a version requirement"),
other.type_name(),
))
}
},
}
}
let mut result = std::collections::BTreeMap::new();
result.insert("satisfied".to_string(), Value::Bool(missing.is_empty()));
result.insert("missing".to_string(), Value::Array(missing));
Ok(Value::Record(result))
}
fn bi_platform_check_reqs(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
bi_platform_require(args, input)
}
fn bi_platform_satisfies(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
platform_refuse_input("platform_satisfies", &input)?;
// A missing answer is an error, not `false`: "not satisfied" and "could not
// say" are different things to an agent deciding whether to proceed.
let Value::Record(r) = bi_platform_require(args, None)? else {
return Err(crate::safety::bad_state(
"platform_satisfies",
"platform_require did not answer a record",
"platform_require",
));
};
match r.get("satisfied") {
Some(Value::Bool(sat)) => Ok(Value::Bool(*sat)),
_ => Err(crate::safety::bad_state(
"platform_satisfies",
"platform_require answered no satisfied flag",
"platform_require",
)),
}
}
fn bi_platform_diff(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
platform_refuse_input("platform_diff", &input)?;
// Compare two snapshots
let snap1 = match args.first() {
Some(Value::Record(r)) => r.clone(),
other => {
return Err(crate::safety::bad_arg(
"platform_diff",
"Record",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let snap2 = match args.get(1) {
Some(Value::Record(r)) => r.clone(),
other => {
return Err(crate::safety::bad_arg(
"platform_diff",
"Record",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let mut diff = std::collections::BTreeMap::new();
let mut added = Vec::new();
let mut removed = Vec::new();
let mut changed = Vec::new();
// Find added/changed in snap2
for (key, val2) in &snap2 {
match snap1.get(key) {
None => added.push(Value::Str(key.clone())),
Some(val1) if val1 != val2 => {
let mut change = std::collections::BTreeMap::new();
change.insert("key".to_string(), Value::Str(key.clone()));
change.insert("old".to_string(), val1.clone());
change.insert("new".to_string(), val2.clone());
changed.push(Value::Record(change));
}
_ => {}
}
}
// Find removed in snap2
for key in snap1.keys() {
if !snap2.contains_key(key) {
removed.push(Value::Str(key.clone()));
}
}
diff.insert("added".to_string(), Value::Array(added));
diff.insert("removed".to_string(), Value::Array(removed));
diff.insert("changed".to_string(), Value::Array(changed));
Ok(Value::Record(diff))
}
fn bi_platform_compatible(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
platform_refuse_input("platform_compatible", &input)?;
// Check if two snapshots are compatible (same OS family, arch)
let snap1 = match args.first() {
Some(Value::Record(r)) => r,
other => {
return Err(crate::safety::bad_arg(
"platform_compatible",
"Record",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let snap2 = match args.get(1) {
Some(Value::Record(r)) => r,
other => {
return Err(crate::safety::bad_arg(
"platform_compatible",
"Record",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
// A key missing from both records compared equal (None == None), so two
// empty records were "compatible". Each record must carry all three.
for key in ["os", "arch", "family"] {
for snap in [snap1, snap2] {
if !snap.contains_key(key) {
return Err(crate::safety::bad_arg(
"platform_compatible",
"records from platform_snapshot(), each with os, arch and family",
&format!("a record without {key}"),
));
}
}
}
let os_match = snap1.get("os") == snap2.get("os");
let arch_match = snap1.get("arch") == snap2.get("arch");
let family_match = snap1.get("family") == snap2.get("family");
Ok(Value::Bool(os_match && arch_match && family_match))
}
fn bi_platform_db_init(_args: Vec<Value>, input: Option<Value>) -> Result<Value> {
platform_refuse_input("platform_db_init", &input)?;
let db_path = platform_db_path()?;
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| crate::safety::fs_error("platform_db_init", parent, &e))?;
}
if !db_path.exists() {
std::fs::write(&db_path, "{}")
.map_err(|e| crate::safety::fs_error("platform_db_init", &db_path, &e))?;
}
Ok(Value::Str(db_path.to_string_lossy().to_string()))
}
fn bi_platform_db_store(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
platform_refuse_input("platform_db_store", &input)?;
let key =
platform_str_arg("platform_db_store", &args, 0)?.unwrap_or_else(|| "default".to_string());
let snapshot = match args.get(1) {
Some(v) => v.clone(),
None => bi_platform_snapshot(vec![], None)?,
};
let db_path = platform_db_path()?;
bi_platform_db_init(vec![], None)?;
// An unreadable store used to read as empty and be written back with only
// the new key: every other entry was destroyed. The read error is now the
// answer.
let content = read_text_capped("platform_db_store", &db_path)?;
let mut db = platform_store_parse("platform_db_store", &db_path, &content)?;
db.insert(key, value_to_json(snapshot));
// The store had no size limit and was read whole on every call; a store
// past the file limit could no longer be read back.
let text = serde_json::to_string_pretty(&db)?;
let max = crate::security::max_file_size_bytes()?;
if text.len() as u64 > max {
return Err(crate::safety::budget_exceeded(
"platform_db_store",
format!(
"the platform store would be {} bytes, more than the file limit of {max}",
text.len()
),
"remove entries from the store, or raise max_file_size_mb",
));
}
std::fs::write(&db_path, text)
.map_err(|e| crate::safety::fs_error("platform_db_store", &db_path, &e))?;
Ok(Value::Bool(true))
}
fn bi_platform_db_load(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let key = args
.first()
.and_then(|v| v.as_str().ok())
.unwrap_or("default");
let db_path = platform_db_path()?;
if !db_path.exists() {
return Ok(Value::Null);
}
let content = read_text_capped("platform_db_load", &db_path)?;
// A corrupt store read as empty, so every key answered null.
let db: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(&content).map_err(|e| {
crate::safety::bad_state(
"platform_db_load",
&format!(
"the platform store {} is not valid JSON: {e}",
db_path.display()
),
"repair or delete the file; platform_db_store recreates it",
)
})?;
match db.get(key) {
Some(val) => Ok(json_to_value(val.clone())),
None => Ok(Value::Null),
}
}
fn bi_platform_db_list(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let db_path = platform_db_path()?;
if !db_path.exists() {
return Ok(Value::Array(vec![]));
}
let content = std::fs::read_to_string(&db_path)
.map_err(|e| crate::safety::fs_error("platform_db_list", &db_path, &e))?;
// A corrupt file was listed as empty before. It is now named as bad state.
let db: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(&content).map_err(|e| {
crate::safety::bad_state(
"platform_db_list",
&format!("{} is not a JSON object: {e}", db_path.display()),
"a valid platform DB file; repair or remove it",
)
})?;
let keys: Vec<Value> = db.keys().map(|k| Value::Str(k.clone())).collect();
Ok(Value::Array(keys))
}
fn bi_platform_db_delete(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
platform_refuse_input("platform_db_delete", &input)?;
// The key is required (the declaration says so). Validated before the guard,
// so a missing key is E_BAD_ARG and never reaches the approval gate.
let key = platform_str_arg("platform_db_delete", &args, 0)?
.ok_or_else(|| crate::safety::bad_arg("platform_db_delete", "a key: String", "nothing"))?;
crate::safety::guard(crate::safety::GuardCtx {
builtin: "platform_db_delete",
effect: crate::safety::Effect::Destructive,
what: "db_delete",
targets: vec![key.clone()],
blast_radius: serde_json::json!({ "key": key }),
reversible: false,
fs_paths: false,
})?;
let db_path = platform_db_path()?;
if !db_path.exists() {
return Ok(Value::Bool(false));
}
let content = std::fs::read_to_string(&db_path)
.map_err(|e| crate::safety::fs_error("platform_db_delete", &db_path, &e))?;
let mut db = platform_store_parse("platform_db_delete", &db_path, &content)?;
let existed = db.remove(&key).is_some();
// Written only when something was removed: a miss leaves the file untouched.
if existed {
std::fs::write(&db_path, serde_json::to_string_pretty(&db)?)
.map_err(|e| crate::safety::fs_error("platform_db_delete", &db_path, &e))?;
}
Ok(Value::Bool(existed))
}
fn bi_platform_db_compare(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
platform_refuse_input("platform_db_compare", &input)?;
let key_of = |index: usize| -> Result<String> {
platform_str_arg("platform_db_compare", &args, index)?.ok_or_else(|| {
crate::safety::bad_arg("platform_db_compare", "two keys: String, String", "fewer")
})
};
let key1 = key_of(0)?;
let key2 = key_of(1)?;
// A key that is not in the store used to load as null, and the diff then
// refused a null as "expected Record, got Null". Name the miss instead.
let mut snapshots = Vec::with_capacity(2);
for key in [key1, key2] {
match bi_platform_db_load(vec![Value::Str(key.clone())], None)? {
Value::Null => {
return Err(crate::safety::not_found(
"platform_db_compare",
"platform snapshot",
&key,
))
}
snapshot => snapshots.push(snapshot),
}
}
bi_platform_diff(snapshots, None)
}
fn bi_platform_db_export(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let file_path = args.first().and_then(|v| v.as_str().ok());
let db_path = platform_db_path()?;
// With no store yet, a requested export file was never written: the
// call answered "{}" instead of the path.
let content = if db_path.exists() {
std::fs::read_to_string(&db_path)
.map_err(|e| crate::safety::fs_error("platform_db_export", &db_path, &e))?
} else {
"{}".to_string()
};
if let Some(path) = file_path {
// The destination was written with no workspace check at all.
let path = guard_local_write("platform_db_export", path)?;
crate::tx::snapshot(&path); // record pre-export state if a transaction is active
write_file_atomically("platform_db_export", &path, content.as_bytes())?;
Ok(Value::Str(path))
} else {
Ok(Value::Str(content))
}
}
fn bi_platform_db_import(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
platform_refuse_input("platform_db_import", &input)?;
let source = platform_str_arg("platform_db_import", &args, 0)?.ok_or_else(|| {
crate::safety::bad_arg(
"platform_db_import",
"a JSON object, or the path of a file containing one",
"nothing",
)
})?;
// Check if it's a file path or JSON string
let content = if std::path::Path::new(&source).exists() {
read_text_capped("platform_db_import", &source)?
} else {
source.clone()
};
// Validate JSON
let imported: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&content)
.map_err(|e| {
crate::safety::bad_arg(
"platform_db_import",
"a JSON object, or the path of a file containing one",
&e.to_string(),
)
})?;
let db_path = platform_db_path()?;
bi_platform_db_init(vec![], None)?;
// Merge with existing. A corrupt store used to read as empty here and be
// overwritten by the import, losing its entries.
let existing = std::fs::read_to_string(&db_path)
.map_err(|e| crate::safety::fs_error("platform_db_import", &db_path, &e))?;
let mut db = platform_store_parse("platform_db_import", &db_path, &existing)?;
for (k, v) in imported {
db.insert(k, v);
}
std::fs::write(&db_path, serde_json::to_string_pretty(&db)?)
.map_err(|e| crate::safety::fs_error("platform_db_import", &db_path, &e))?;
// The number of keys the store holds after the merge, not the number imported.
Ok(Value::Int(db.len() as i64))
}
fn bi_platform_fingerprint(_args: Vec<Value>, input: Option<Value>) -> Result<Value> {
platform_refuse_input("platform_fingerprint", &input)?;
let snapshot = bi_platform_snapshot(vec![], None)?;
if let Value::Record(r) = snapshot {
if let Some(fp @ Value::Str(_)) = r.get("fingerprint") {
return Ok(fp.clone());
}
}
// The snapshot always carries one, so its absence is a defect, not an
// answer. It used to be the string "unknown".
Err(crate::safety::bad_state(
"platform_fingerprint",
"the platform snapshot carries no fingerprint",
"platform_snapshot",
))
}
fn bi_platform_hash(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let data = match args.first() {
Some(v) => format!("{:?}", v),
None => "".to_string(),
};
Ok(Value::Str(format!("{:x}", md5_simple(&data))))
}
// ============================================================================
// Platform Categorized Tool & Library Detection
// ============================================================================
fn bi_platform_compilers(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let compilers = [
"gcc", "g++", "clang", "clang++", "rustc", "go", "javac", "swift", "kotlinc", "scalac",
"ghc", "ocaml", "fpc", "gfortran", "nvcc", "hipcc", "dmd", "ldc2", "gdc", "zig", "nim",
"crystal", "tcc", "pcc", "icc", "icx", "nvc", "nvc++", "emcc", "wasm-ld",
];
let mut versions = std::collections::BTreeMap::new();
for (tool, ver) in probe_tool_versions(&compilers) {
let tool = tool.as_str();
versions.insert(tool.to_string(), Value::Str(ver));
}
Ok(Value::Record(versions))
}
fn bi_platform_build_systems(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let build_systems = [
"make", "cmake", "ninja", "meson", "bazel", "buck2", "scons", "gradle", "maven", "ant",
"cargo", "cabal", "stack", "mix", "dune", "xmake", "premake5", "waf", "rake", "shake",
"tup", "gn", "gyp", "qmake", "autoconf", "automake", "libtool",
];
let mut versions = std::collections::BTreeMap::new();
for (tool, ver) in probe_tool_versions(&build_systems) {
let tool = tool.as_str();
versions.insert(tool.to_string(), Value::Str(ver));
}
Ok(Value::Record(versions))
}
fn bi_platform_runtimes(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let runtimes = [
"python", "python3", "node", "deno", "bun", "ruby", "php", "perl", "lua", "luajit",
"julia", "R", "java", "dotnet", "mono", "erlang", "elixir", "racket", "sbcl", "clisp",
"guile", "tcl", "tk", "groovy", "scala", "kotlin", "clojure",
];
let mut versions = std::collections::BTreeMap::new();
for (tool, ver) in probe_tool_versions(&runtimes) {
let tool = tool.as_str();
versions.insert(tool.to_string(), Value::Str(ver));
}
Ok(Value::Record(versions))
}
fn bi_platform_pkg_lang(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let pkg_lang = [
"pip", "pip3", "pipx", "poetry", "pdm", "uv", "conda", "mamba", "npm", "yarn", "pnpm",
"bun", "gem", "bundler", "composer", "cargo", "go", "nuget", "dotnet", "maven", "gradle",
"cabal", "stack", "opam", "mix", "hex", "rebar3", "cpan", "luarocks", "pub", "swift",
"spm", "vcpkg", "conan", "hunter", "cpm",
];
let mut versions = std::collections::BTreeMap::new();
for (tool, ver) in probe_tool_versions(&pkg_lang) {
let tool = tool.as_str();
versions.insert(tool.to_string(), Value::Str(ver));
}
Ok(Value::Record(versions))
}
fn bi_platform_containers(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let containers = [
"docker",
"podman",
"buildah",
"skopeo",
"containerd",
"runc",
"nerdctl",
"crictl",
"ctr",
"lxc",
"lxd",
"vagrant",
"packer",
"qemu-system-x86_64",
"vboxmanage",
"multipass",
"lima",
"colima",
"rancher",
"k3d",
"kind",
"minikube",
"microk8s",
];
let mut versions = std::collections::BTreeMap::new();
for (tool, ver) in probe_tool_versions(&containers) {
let tool = tool.as_str();
versions.insert(tool.to_string(), Value::Str(ver));
}
Ok(Value::Record(versions))
}
fn bi_platform_cloud_clis(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let cloud = [
"aws",
"az",
"gcloud",
"doctl",
"linode-cli",
"vultr-cli",
"oci",
"ibmcloud",
"hcloud",
"exo",
"scaleway",
"flyctl",
"netlify",
"vercel",
"heroku",
"railway",
"render",
"cf",
"wrangler",
"supabase",
"firebase",
"amplify",
];
let mut versions = std::collections::BTreeMap::new();
for (tool, ver) in probe_tool_versions(&cloud) {
let tool = tool.as_str();
versions.insert(tool.to_string(), Value::Str(ver));
}
Ok(Value::Record(versions))
}
fn bi_platform_databases(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let databases = [
"psql",
"mysql",
"sqlite3",
"mongosh",
"redis-cli",
"mongod",
"pg_dump",
"mysqldump",
"cqlsh",
"influx",
"clickhouse-client",
"cockroach",
"tiup",
"vitess",
"pgcli",
"mycli",
"litecli",
"duckdb",
"questdb",
"timescaledb",
"surreal",
];
let mut versions = std::collections::BTreeMap::new();
for (tool, ver) in probe_tool_versions(&databases) {
let tool = tool.as_str();
versions.insert(tool.to_string(), Value::Str(ver));
}
Ok(Value::Record(versions))
}
fn bi_platform_linters(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let linters = [
"eslint",
"prettier",
"black",
"ruff",
"mypy",
"pylint",
"flake8",
"rubocop",
"clippy",
"rustfmt",
"gofmt",
"golangci-lint",
"shellcheck",
"hadolint",
"yamllint",
"jsonlint",
"tflint",
"stylelint",
"htmlhint",
"markdownlint",
"actionlint",
"cppcheck",
"clang-tidy",
"clang-format",
"astyle",
"uncrustify",
];
let mut versions = std::collections::BTreeMap::new();
for (tool, ver) in probe_tool_versions(&linters) {
let tool = tool.as_str();
versions.insert(tool.to_string(), Value::Str(ver));
}
Ok(Value::Record(versions))
}
fn bi_platform_vcs(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let vcs = [
"git",
"hg",
"svn",
"fossil",
"darcs",
"bzr",
"cvs",
"git-lfs",
"git-annex",
"dvc",
"gh",
"glab",
"hub",
];
let mut versions = std::collections::BTreeMap::new();
for (tool, ver) in probe_tool_versions(&vcs) {
let tool = tool.as_str();
versions.insert(tool.to_string(), Value::Str(ver));
}
Ok(Value::Record(versions))
}
fn bi_platform_iac_tools(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let iac = [
"kubectl",
"helm",
"kustomize",
"k3s",
"k3d",
"kind",
"minikube",
"terraform",
"terragrunt",
"pulumi",
"cdktf",
"crossplane",
"ansible",
"ansible-playbook",
"chef",
"puppet",
"salt",
"vagrant",
"packer",
"nomad",
"consul",
"vault",
"argocd",
"flux",
"tekton",
"skaffold",
"tilt",
"devspace",
];
let mut versions = std::collections::BTreeMap::new();
for (tool, ver) in probe_tool_versions(&iac) {
let tool = tool.as_str();
versions.insert(tool.to_string(), Value::Str(ver));
}
Ok(Value::Record(versions))
}
fn bi_platform_libs(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut libs = std::collections::BTreeMap::new();
// Check pkg-config for common libraries
let check_libs = [
"openssl",
"libssl",
"libcrypto",
"zlib",
"libpng",
"libjpeg",
"libcurl",
"sqlite3",
"libxml-2.0",
"libffi",
"glib-2.0",
"gtk+-3.0",
"qt5",
"sdl2",
"glfw3",
"vulkan",
"opencv4",
"protobuf",
"grpc",
"boost",
"eigen3",
"fmt",
"spdlog",
];
for lib in check_libs {
let mut cmd = crate::safety::child_command("pkg-config");
cmd.args(["--modversion", lib]);
if let Ok(out) = bounded_cmd_output("platform_libs", "pkg-config", cmd, PROBE_DEADLINE_SECS)
{
if out.status.success() {
let ver = String::from_utf8_lossy(&out.stdout).trim().to_string();
libs.insert(lib.to_string(), Value::Str(ver));
}
}
}
Ok(Value::Record(libs))
}
fn bi_platform_lib_version(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let lib = args.first().and_then(|v| v.as_str().ok()).unwrap_or("");
crate::safety::reject_option_like("platform_lib_version", &[lib.to_string()])?;
// A missing pkg-config and an absent library were both null, so "not
// installed" could not be told from "could not ask". Null now means only
// the library is unknown to pkg-config.
let mut cmd = crate::safety::child_command("pkg-config");
cmd.args(["--modversion", lib]);
let out = bounded_cmd_output(
"platform_lib_version",
"pkg-config",
cmd,
PROBE_DEADLINE_SECS,
)?;
if out.status.success() {
return Ok(Value::Str(
String::from_utf8_lossy(&out.stdout).trim().to_string(),
));
}
// pkg-config and pkgconf say the module was not found ("Package 'x' was
// not found", "No package 'x' found"); any other failure, such as a
// broken .pc file, had also read as an unknown library.
let stderr = String::from_utf8_lossy(&out.stderr);
if stderr.contains("not found") || stderr.contains("No package") {
return Ok(Value::Null);
}
Err(crate::safety::tool_failed(
"platform_lib_version",
"pkg-config --modversion",
out.status.code(),
&stderr,
))
}
/// Where a Linux libstdc++ is installed, read in this order. A host with none
/// of them has no libstdc++ to report.
#[cfg(target_os = "linux")]
const LIBSTDCXX_CANDIDATES: [&str; 4] = [
"/usr/lib/x86_64-linux-gnu/libstdc++.so.6",
"/usr/lib/aarch64-linux-gnu/libstdc++.so.6",
"/usr/lib64/libstdc++.so.6",
"/usr/lib/libstdc++.so.6",
];
/// The glibc line from `ldd --version`. Musl's ldd exits non-zero and names
/// musl: that is the answer "no glibc", `Ok(None)`. Any other non-zero exit, or
/// a clean exit with no line, is `Err` with the text to refuse with. A failed
/// listing must not read as an absent library.
#[cfg(any(target_os = "linux", test))]
fn glibc_from_ldd(success: bool, stdout: &str, stderr: &str) -> Result<Option<String>, String> {
if success {
return stdout
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.map(|l| Some(l.to_string()))
.ok_or_else(|| "ldd printed no version line".to_string());
}
if format!("{stdout}{stderr}")
.to_ascii_lowercase()
.contains("musl")
{
return Ok(None);
}
Err(stderr.trim().to_string())
}
/// The last `GLIBCXX_` tag in `strings` output. A failed `strings` is `Err`,
/// whatever it printed; a successful one with no tags is `Ok(None)`.
#[cfg(any(target_os = "linux", test))]
fn libstdcxx_from_strings(
success: bool,
stdout: &str,
stderr: &str,
) -> Result<Option<String>, String> {
if !success {
return Err(stderr.trim().to_string());
}
Ok(stdout
.lines()
.rfind(|l| l.starts_with("GLIBCXX_"))
.map(str::to_string))
}
/// The first line of `xcodebuild -version`. A non-zero exit is `None`: on a
/// Mac with only the Command Line Tools that is the answer "no Xcode".
#[cfg(any(target_os = "macos", test))]
fn xcode_from_xcodebuild(success: bool, stdout: &str) -> Option<String> {
if !success {
return None;
}
stdout
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.map(str::to_string)
}
/// The compiler banner line from `cl` stderr. `cl` with no arguments prints
/// it and exits non-zero, so the banner, not the status, is what is read.
#[cfg(any(target_os = "windows", test))]
fn msvc_from_cl(stderr: &str) -> Option<String> {
stderr
.lines()
.map(str::trim)
.find(|l| l.contains("Version"))
.map(str::to_string)
}
/// Whether a failed probe is a program that is not installed (E_TOOL_MISSING).
/// Not gated to Windows: the GPU probes use it on every host.
fn is_tool_missing(err: &anyhow::Error) -> bool {
err.downcast_ref::<crate::safety::SafetyError>()
.is_some_and(|se| se.code == crate::safety::ErrorCode::ToolMissing)
}
/// The toolchain facts this host has. A probe that fails is refused, never read
/// as absent. A fact that is optional by design (Xcode, MSVC, the Windows SDK
/// variables) is omitted when its tool says the thing is not there.
fn bi_platform_system_libs(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut info = std::collections::BTreeMap::new();
#[cfg(target_os = "linux")]
{
let mut cmd = crate::safety::child_command("ldd");
cmd.arg("--version");
let out = bounded_cmd_output("platform_system_libs", "ldd", cmd, PROBE_DEADLINE_SECS)?;
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
match glibc_from_ldd(out.status.success(), &stdout, &stderr) {
Ok(Some(line)) => {
info.insert("glibc".to_string(), Value::Str(line));
}
Ok(None) => {}
Err(stderr) => {
return Err(crate::safety::tool_failed(
"platform_system_libs",
"ldd",
out.status.code(),
&stderr,
));
}
}
// The first candidate that answers wins. A candidate whose `strings`
// failed hands over to the next, and the last failure is refused only
// when no candidate answers.
let mut found: Option<String> = None;
let mut failure: Option<anyhow::Error> = None;
for path in LIBSTDCXX_CANDIDATES
.iter()
.filter(|p| std::path::Path::new(p).exists())
{
let mut cmd = crate::safety::child_command("strings");
cmd.arg(path);
match bounded_cmd_output("platform_system_libs", "strings", cmd, PROBE_DEADLINE_SECS) {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
match libstdcxx_from_strings(out.status.success(), &stdout, &stderr) {
Ok(Some(v)) => {
found = Some(v);
break;
}
Ok(None) => {}
Err(stderr) => {
failure = Some(crate::safety::tool_failed(
"platform_system_libs",
"strings",
out.status.code(),
&stderr,
));
}
}
}
Err(e) => failure = Some(e),
}
}
match (found, failure) {
(Some(v), _) => {
info.insert("libstdc++".to_string(), Value::Str(v));
}
(None, Some(e)) => return Err(e),
(None, None) => {}
}
}
#[cfg(target_os = "macos")]
{
let mut cmd = crate::safety::child_command("xcrun");
cmd.args(["--show-sdk-version"]);
let out = bounded_cmd_output("platform_system_libs", "xcrun", cmd, PROBE_DEADLINE_SECS)?;
let sdk = probe_stdout("platform_system_libs", "xcrun", out)?;
info.insert("macos_sdk".to_string(), Value::Str(sdk));
let mut cmd = crate::safety::child_command("xcodebuild");
cmd.arg("-version");
let out = bounded_cmd_output(
"platform_system_libs",
"xcodebuild",
cmd,
PROBE_DEADLINE_SECS,
)?;
if let Some(line) =
xcode_from_xcodebuild(out.status.success(), &String::from_utf8_lossy(&out.stdout))
{
info.insert("xcode".to_string(), Value::Str(line));
}
}
#[cfg(target_os = "windows")]
{
// Get Windows SDK version
if let Ok(sdk_path) = std::env::var("WindowsSdkDir") {
info.insert("windows_sdk_path".to_string(), Value::Str(sdk_path));
}
if let Ok(sdk_ver) = std::env::var("WindowsSDKVersion") {
info.insert("windows_sdk".to_string(), Value::Str(sdk_ver));
}
// Get Visual Studio version
if let Ok(vs_path) = std::env::var("VSINSTALLDIR") {
info.insert("vs_path".to_string(), Value::Str(vs_path));
}
// `cl` is on PATH only in a Developer prompt, so a missing `cl` is the
// answer "no MSVC". Any other failure to run it is refused.
let cmd = crate::safety::child_command("cl");
match bounded_cmd_output("platform_system_libs", "cl", cmd, PROBE_DEADLINE_SECS) {
Ok(out) => match msvc_from_cl(&String::from_utf8_lossy(&out.stderr)) {
Some(line) => {
info.insert("msvc".to_string(), Value::Str(line));
}
None => {
return Err(crate::safety::bad_state(
"platform_system_libs",
"unexpected `cl` output",
"a `Microsoft (R) C/C++ Optimizing Compiler Version` banner",
));
}
},
Err(e) if is_tool_missing(&e) => {}
Err(e) => return Err(e),
}
}
Ok(Value::Record(info))
}
#[cfg(test)]
mod platform_probe_status_tests {
use super::*;
/// platform_snapshot's fingerprint hashes os_version and build, so the
/// kernel-read strings must be the ones `cmd /c ver` gave. Compared only
/// where ver prints the English line it can be compared with.
#[cfg(target_os = "windows")]
#[test]
fn the_version_strings_are_the_ones_ver_printed() {
let mut cmd = crate::safety::child_command("cmd");
cmd.args(["/c", "ver"]);
let out = bounded_cmd_output("platform_os_version", "cmd", cmd, PROBE_DEADLINE_SECS)
.expect("cmd runs");
let ver = String::from_utf8_lossy(&out.stdout).trim().to_string();
let Value::Str(line) = bi_platform_os_version(vec![], None).unwrap() else {
panic!("platform_os_version is not a String");
};
if ver.contains("[Version ") {
assert_eq!(line, ver);
}
let inner = line
.strip_prefix("Microsoft Windows [Version ")
.and_then(|v| v.strip_suffix(']'))
.expect("the ver form");
let parts: Vec<&str> = inner.split('.').collect();
let str_of = |v: Value| match v {
Value::Str(s) => s,
other => panic!("not a String: {other:?}"),
};
assert_eq!(
str_of(bi_platform_kernel(vec![], None).unwrap()),
parts[..3].join(".")
);
assert_eq!(str_of(bi_platform_build(vec![], None).unwrap()), parts[2]);
assert_eq!(str_of(bi_sys_kernel(vec![], None).unwrap()), inner);
}
#[test]
fn os_release_pretty_name_is_unquoted_and_absent_when_missing() {
let text = "NAME=\"Debian GNU/Linux\"\nPRETTY_NAME=\"Debian GNU/Linux 12 (bookworm)\"\n";
assert_eq!(
os_release_pretty_name(text).as_deref(),
Some("Debian GNU/Linux 12 (bookworm)")
);
assert_eq!(os_release_pretty_name("NAME=\"x\"\n"), None);
}
#[test]
fn glibc_from_ldd_reads_the_first_line_on_success() {
assert_eq!(
glibc_from_ldd(true, "ldd (GNU libc) 2.39\nCopyright\n", ""),
Ok(Some("ldd (GNU libc) 2.39".to_string()))
);
}
#[test]
fn glibc_from_ldd_clean_exit_without_a_line_is_refused() {
assert!(glibc_from_ldd(true, "\n", "").is_err());
}
#[test]
fn glibc_from_ldd_musl_is_no_glibc_not_a_failure() {
assert_eq!(
glibc_from_ldd(false, "", "musl libc (x86_64)\nVersion 1.2.4"),
Ok(None)
);
}
#[test]
fn glibc_from_ldd_failed_listing_is_refused_not_read_as_absent() {
// The old code left glibc out of the record on a non-zero ldd, which
// read as "no glibc". It is a refusal now.
assert_eq!(
glibc_from_ldd(false, "", "ldd: boom"),
Err("ldd: boom".to_string())
);
}
#[test]
fn libstdcxx_from_strings_takes_the_last_glibcxx_tag() {
assert_eq!(
libstdcxx_from_strings(true, "junk\nGLIBCXX_3.4.29\nGLIBCXX_3.4.32\n", ""),
Ok(Some("GLIBCXX_3.4.32".to_string()))
);
}
#[test]
fn libstdcxx_from_failed_strings_is_refused_even_when_it_printed_tags() {
// The old code parsed the stdout of a failed `strings` as the answer.
assert!(libstdcxx_from_strings(false, "GLIBCXX_3.4.29\n", "strings: boom").is_err());
}
#[test]
fn libstdcxx_without_glibcxx_tags_is_no_answer() {
assert_eq!(libstdcxx_from_strings(true, "nothing here\n", ""), Ok(None));
}
#[test]
fn xcode_is_absent_when_xcodebuild_fails_on_a_clt_only_host() {
assert_eq!(
xcode_from_xcodebuild(
false,
"xcode-select: error: tool 'xcodebuild' requires Xcode"
),
None
);
}
#[test]
fn xcode_takes_the_first_line_of_version_output() {
assert_eq!(
xcode_from_xcodebuild(true, "Xcode 15.4\nBuild version 15F31d\n").as_deref(),
Some("Xcode 15.4")
);
}
#[test]
fn msvc_reads_the_banner_line_from_stderr() {
let stderr = "Microsoft (R) C/C++ Optimizing Compiler Version 19.39.33523 for x64\r\nCopyright (C) Microsoft Corporation.\r\n\r\nusage: cl [ option... ]\r\n";
assert_eq!(
msvc_from_cl(stderr).as_deref(),
Some("Microsoft (R) C/C++ Optimizing Compiler Version 19.39.33523 for x64")
);
}
#[test]
fn msvc_without_a_banner_is_none() {
assert_eq!(msvc_from_cl(""), None);
assert_eq!(msvc_from_cl("error: something else"), None);
}
}
fn bi_platform_sdk_version(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let sdk = args.first().and_then(|v| v.as_str().ok()).unwrap_or("");
match sdk {
"android" => {
if let Ok(home) =
std::env::var("ANDROID_HOME").or_else(|_| std::env::var("ANDROID_SDK_ROOT"))
{
// Try to read build-tools version. A missing directory is an
// SDK that is not installed (Null below); an unreadable one is
// an error, not an empty list.
let bt_path = std::path::Path::new(&home).join("build-tools");
match std::fs::read_dir(&bt_path) {
Ok(entries) => {
let versions: Vec<String> = entries
.filter_map(|e| e.ok())
.filter_map(|e| e.file_name().into_string().ok())
.collect();
return Ok(Value::Array(versions.into_iter().map(Value::Str).collect()));
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
return Err(crate::safety::fs_error(
"platform_sdk_version",
&bt_path,
&e,
))
}
}
}
}
"ios" | "macos" | "xcode" => {
// A failing xcrun used to answer "" (or Null when it failed to
// start). Absent xcrun is still "not installed".
let mut cmd = crate::safety::child_command("xcrun");
cmd.args(["--show-sdk-version"]);
if let Some(version) = probe_text_if_installed("platform_sdk_version", "xcrun", cmd)? {
return Ok(Value::Str(version));
}
}
"windows" => {
if let Ok(ver) = std::env::var("WindowsSDKVersion") {
return Ok(Value::Str(ver));
}
}
"dotnet" => {
// A probe error used to fall through to Null ("not installed").
let mut cmd = crate::safety::child_command("dotnet");
cmd.args(["--list-sdks"]);
if let Some(text) = probe_text_if_installed("platform_sdk_version", "dotnet", cmd)? {
let sdks: Vec<Value> = text.lines().map(|l| Value::Str(l.to_string())).collect();
return Ok(Value::Array(sdks));
}
}
"java" | "jdk" => {
if let Ok(home) = std::env::var("JAVA_HOME") {
return Ok(Value::Str(home));
}
}
// An SDK name this does not know answered null, which reads as
// "not installed".
other => {
return Err(crate::safety::bad_arg(
"platform_sdk_version",
"sdk android, ios, macos, xcode, windows, dotnet, java or jdk",
other,
))
}
}
// A known SDK that is not installed.
Ok(Value::Null)
}
fn bi_platform_libc(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
#[cfg(target_os = "linux")]
{
let mut cmd = crate::safety::child_command("ldd");
cmd.arg("--version");
if let Ok(out) = bounded_cmd_output("platform_libc", "ldd", cmd, PROBE_DEADLINE_SECS) {
let ver = String::from_utf8_lossy(&out.stdout);
if let Some(line) = ver.lines().next() {
return Ok(Value::Str(line.to_string()));
}
}
// Alternative: check /lib
if let Ok(entries) = std::fs::read_dir("/lib/x86_64-linux-gnu") {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with("libc.so") || name.starts_with("libc-") {
return Ok(Value::Str(name));
}
}
}
}
#[cfg(target_os = "macos")]
{
return Ok(Value::Str("libSystem (macOS)".to_string()));
}
#[cfg(target_os = "windows")]
{
// MSVC runtime
let mut cmd = crate::safety::child_command("where");
cmd.arg("ucrtbase.dll");
if let Ok(out) = bounded_cmd_output("platform_libc", "where", cmd, PROBE_DEADLINE_SECS) {
if out.status.success() {
return Ok(Value::Str("ucrt (Universal CRT)".to_string()));
}
}
return Ok(Value::Str("msvcrt".to_string()));
}
Ok(Value::Str("unknown".to_string()))
}
/// The highest `<prefix>N.N.N` symbol-version string in a binary, compared
/// numerically: by string order `GLIBCXX_3.4.9` outranks `GLIBCXX_3.4.33`.
#[cfg(target_os = "linux")]
fn highest_symbol_version(bytes: &[u8], prefix: &str) -> Option<String> {
let p = prefix.as_bytes();
let mut best: Option<(Vec<u64>, String)> = None;
let mut i = 0;
while i + p.len() <= bytes.len() {
if &bytes[i..i + p.len()] == p {
let start = i + p.len();
let end = bytes[start..]
.iter()
.position(|b| !(b.is_ascii_digit() || *b == b'.'))
.map_or(bytes.len(), |n| start + n);
let ver = std::str::from_utf8(&bytes[start..end]).unwrap_or("");
let key: Vec<u64> = ver.split('.').filter_map(|x| x.parse().ok()).collect();
if !key.is_empty() && best.as_ref().is_none_or(|(k, _)| key > *k) {
best = Some((key, ver.to_string()));
}
i = end;
} else {
i += 1;
}
}
best.map(|(_, v)| v)
}
#[cfg(all(test, target_os = "linux"))]
mod highest_symbol_version_tests {
use super::highest_symbol_version;
#[test]
fn versions_compare_numerically_not_as_strings() {
let bin = b"\0GLIBCXX_3.4.9\0GLIBCXX_3.4.33\0GLIBCXX_3.4.4\0CXXABI_1.3.15\0CXXABI_1.3.9\0";
assert_eq!(
highest_symbol_version(bin, "GLIBCXX_").as_deref(),
Some("3.4.33")
);
assert_eq!(
highest_symbol_version(bin, "CXXABI_").as_deref(),
Some("1.3.15")
);
assert_eq!(highest_symbol_version(bin, "NOPE_"), None);
}
}
/// Largest C++ runtime library read on Linux; a larger file is refused.
#[cfg(target_os = "linux")]
const LIBSTDCXX_READ_LIMIT: u64 = 64 * 1024 * 1024;
/// Most of msvcp140.dll that is read on Windows. The version resource sits
/// well inside this bound; the read stops there rather than failing.
#[cfg(target_os = "windows")]
const MSVCP140_READ_LIMIT: u64 = 16 * 1024 * 1024;
/// Open a candidate runtime library. `Ok(None)` means it does not exist, so the
/// caller moves on to the next path. Any other failure to open is a coded error:
/// a file that exists but cannot be read is never skipped.
#[cfg(any(windows, target_os = "linux"))]
fn open_candidate(path: &std::path::Path) -> Result<Option<std::fs::File>> {
match std::fs::File::open(path) {
Ok(file) => Ok(Some(file)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(crate::safety::fs_error("platform_libcpp", path, &e)),
}
}
/// Read at most `limit` bytes of an opened candidate.
#[cfg(any(windows, target_os = "linux"))]
fn read_up_to(file: std::fs::File, path: &std::path::Path, limit: u64) -> Result<Vec<u8>> {
use std::io::Read;
let mut bytes = Vec::new();
file.take(limit)
.read_to_end(&mut bytes)
.map_err(|e| crate::safety::fs_error("platform_libcpp", path, &e))?;
Ok(bytes)
}
/// The file version of the Visual C++ STL runtime, read from the version
/// resource of `%SystemRoot%\System32\msvcp140.dll`. Nothing is made up: a
/// missing runtime or an unreadable version resource is a coded error.
#[cfg(target_os = "windows")]
fn msvcp140_version() -> Result<String> {
let system_root = std::env::var_os("SystemRoot")
.filter(|v| !v.is_empty())
.map_or_else(
|| std::path::PathBuf::from("C:\\Windows"),
std::path::PathBuf::from,
);
let path = system_root.join("System32").join("msvcp140.dll");
let Some(file) = open_candidate(&path)? else {
return Err(crate::safety::bad_state(
"platform_libcpp",
"no MSVC C++ runtime (msvcp140.dll) was found",
"install the Visual C++ Redistributable",
));
};
let bytes = read_up_to(file, &path, MSVCP140_READ_LIMIT)?;
pe_version::pe_file_version(&bytes).ok_or_else(|| {
crate::safety::bad_state(
"platform_libcpp",
"the version resource of msvcp140.dll could not be read",
"reinstall the Visual C++ Redistributable",
)
})
}
/// The version of the system libc++ on macOS: the `current version` in the
/// LC_ID_DYLIB load command of /usr/lib/libc++.1.dylib, as dyld mapped it.
/// The library exists only in the dyld shared cache, so there is no file for
/// `otool -L` to read (it exits 1 on the runner); dlopen maps it from the
/// cache, and its in-memory header carries the same load commands. A library
/// that does not load or a header without LC_ID_DYLIB is a coded error; no
/// version is guessed.
#[cfg(target_os = "macos")]
// libc marks the dyld image accessors deprecated in favour of the mach2
// crate; they are still libSystem's public API.
#[allow(deprecated)]
fn libcxx_version_macos() -> Result<String> {
const LIBCXX: &str = "/usr/lib/libc++.1.dylib";
const LC_ID_DYLIB: u32 = 0xd;
let unreadable = |why: &str| {
crate::safety::bad_state(
"platform_libcpp",
&format!("the version of the system libc++ could not be read: {why}"),
"check that /usr/lib/libc++.1.dylib is present in the dyld shared cache",
)
};
let path = std::ffi::CString::new(LIBCXX).expect("no NUL in a constant");
// SAFETY: a NUL-terminated path; the handle is kept, so the image stays
// mapped while its header is read below.
let handle = unsafe { libc::dlopen(path.as_ptr(), libc::RTLD_LAZY | libc::RTLD_LOCAL) };
if handle.is_null() {
return Err(unreadable("dlopen did not load it"));
}
// SAFETY: dyld's image list is read with its own accessors; an index below
// _dyld_image_count names a mapped image, and a mapped image's header is
// followed by `sizeofcmds` bytes of load commands.
let current = unsafe {
let mut found = None;
for i in 0..libc::_dyld_image_count() {
let name = libc::_dyld_get_image_name(i);
if name.is_null() || std::ffi::CStr::from_ptr(name).to_bytes() != LIBCXX.as_bytes() {
continue;
}
let header = libc::_dyld_get_image_header(i);
if header.is_null() {
continue;
}
let ncmds = (*header).ncmds;
let sizeofcmds = (*header).sizeofcmds as usize;
let start = (header as *const u8).add(std::mem::size_of::<libc::mach_header_64>());
let mut offset = 0usize;
for _ in 0..ncmds {
if offset + 24 > sizeofcmds {
break;
}
let at = start.add(offset);
let cmd = std::ptr::read_unaligned(at as *const u32);
let cmdsize = std::ptr::read_unaligned(at.add(4) as *const u32) as usize;
if cmd == LC_ID_DYLIB {
// dylib_command: cmd, cmdsize, name offset, timestamp,
// current_version, compatibility_version.
found = Some(std::ptr::read_unaligned(at.add(16) as *const u32));
break;
}
if cmdsize < 8 {
break;
}
offset += cmdsize;
}
break;
}
found
};
let Some(v) = current else {
return Err(unreadable("no LC_ID_DYLIB in its mapped header"));
};
// X.Y.Z packed as xxxx.yy.zz.
Ok(format!("{}.{}.{}", v >> 16, (v >> 8) & 0xff, v & 0xff))
}
/// The C++ standard library: {name, version, abi}. The version and abi are
/// null where the platform does not report them.
///
/// On Windows the MSVC STL version comes from msvcp140.dll's version resource.
/// On Linux the libstdc++ symbol versions are read from the library, and an
/// unreadable candidate is a coded error. On macOS the libc++ version is read
/// from dyld's mapping of the library (see `libcxx_version_macos`); the abi
/// stays null.
fn bi_platform_libcpp(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
no_args_or_input("platform_libcpp", &args, &input)?;
// One shape everywhere: {name, version, abi}. It was a Record of symbol
// versions on Linux and a bare String elsewhere, so the same call had two
// types depending on the OS. The Linux side also shelled out to
// `strings` (absent without binutils), picked the "highest" version by
// string order, looked only in x86_64 paths, and fell back to the string
// "unknown".
let lib = |name: &str, version: Option<String>, abi: Option<String>| {
let mut rec = std::collections::BTreeMap::new();
rec.insert("name".to_string(), Value::Str(name.to_string()));
rec.insert(
"version".to_string(),
version.map_or(Value::Null, Value::Str),
);
rec.insert("abi".to_string(), abi.map_or(Value::Null, Value::Str));
Value::Record(rec)
};
#[cfg(target_os = "linux")]
{
for path in [
"/usr/lib/x86_64-linux-gnu/libstdc++.so.6",
"/usr/lib/aarch64-linux-gnu/libstdc++.so.6",
"/usr/lib64/libstdc++.so.6",
"/usr/lib/libstdc++.so.6",
"/lib/x86_64-linux-gnu/libstdc++.so.6",
] {
let path = std::path::Path::new(path);
let Some(file) = open_candidate(path)? else {
continue;
};
let len = file
.metadata()
.map_err(|e| crate::safety::fs_error("platform_libcpp", path, &e))?
.len();
if len > LIBSTDCXX_READ_LIMIT {
return Err(crate::safety::budget_exceeded(
"platform_libcpp",
format!(
"`{}` is {len} bytes, over the {LIBSTDCXX_READ_LIMIT}-byte read limit",
path.display()
),
"the C++ runtime is read only up to this bound; a larger file is refused",
));
}
let bytes = read_up_to(file, path, LIBSTDCXX_READ_LIMIT)?;
return Ok(lib(
"libstdc++",
highest_symbol_version(&bytes, "GLIBCXX_"),
highest_symbol_version(&bytes, "CXXABI_"),
));
}
for path in [
"/usr/lib/x86_64-linux-gnu/libc++.so.1",
"/usr/lib/aarch64-linux-gnu/libc++.so.1",
"/usr/lib/libc++.so.1",
] {
// libc++ reports no version here, so opening it is the whole check.
if open_candidate(std::path::Path::new(path))?.is_some() {
return Ok(lib("libc++", None, None));
}
}
return Err(crate::safety::bad_state(
"platform_libcpp",
"no C++ standard library (libstdc++ or libc++) was found in the usual locations",
"install libstdc++6 or libc++1",
));
}
#[cfg(target_os = "macos")]
{
let version = libcxx_version_macos()?;
return Ok(lib("libc++", Some(version), None));
}
#[cfg(target_os = "windows")]
{
let version = msvcp140_version()?;
return Ok(lib("MSVC STL", Some(version), Some("msvcp140".to_string())));
}
#[allow(unreachable_code)]
Err(crate::safety::unimplemented(
"platform_libcpp",
"detecting the C++ standard library is implemented for Linux, macOS and Windows only; NOTHING WAS INSPECTED",
"",
))
}
fn bi_platform_ssl_version(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// Try openssl first
let mut cmd = crate::safety::child_command("openssl");
cmd.arg("version");
if let Ok(out) = bounded_cmd_output("platform_ssl_version", "openssl", cmd, PROBE_DEADLINE_SECS)
{
if out.status.success() {
return Ok(Value::Str(
String::from_utf8_lossy(&out.stdout).trim().to_string(),
));
}
}
// Try pkg-config
let mut cmd = crate::safety::child_command("pkg-config");
cmd.args(["--modversion", "openssl"]);
if let Ok(out) = bounded_cmd_output(
"platform_ssl_version",
"pkg-config",
cmd,
PROBE_DEADLINE_SECS,
) {
if out.status.success() {
return Ok(Value::Str(
String::from_utf8_lossy(&out.stdout).trim().to_string(),
));
}
}
Ok(Value::Null)
}
fn bi_platform_cuda_version(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut info = std::collections::BTreeMap::new();
// CUDA Toolkit version
// nvcc's output was recorded without checking its exit status, so a failing
// nvcc filled in the toolkit line from whatever it printed. A tool that is
// not installed is still just absent.
let mut cmd = crate::safety::child_command("nvcc");
cmd.arg("--version");
if let Some(output) = probe_text_if_installed("platform_cuda_version", "nvcc", cmd)? {
for line in output.lines() {
if line.contains("release") {
info.insert(
"cuda_toolkit".to_string(),
Value::Str(line.trim().to_string()),
);
break;
}
}
}
// NVIDIA driver version
#[cfg(target_os = "linux")]
{
let mut cmd = crate::safety::child_command("nvidia-smi");
cmd.args(["--query-gpu=driver_version", "--format=csv,noheader"]);
// A failing nvidia-smi used to be skipped; it is an error now.
if let Some(ver) = probe_text_if_installed("platform_cuda_version", "nvidia-smi", cmd)? {
info.insert("nvidia_driver".to_string(), Value::Str(ver));
}
}
#[cfg(target_os = "windows")]
{
let cmd = crate::safety::child_command("nvidia-smi");
// A failing nvidia-smi used to be skipped; it is an error now.
if let Some(output) = probe_text_if_installed("platform_cuda_version", "nvidia-smi", cmd)? {
for line in output.lines() {
if line.contains("Driver Version") {
info.insert(
"nvidia_driver".to_string(),
Value::Str(line.trim().to_string()),
);
break;
}
}
}
}
// cuDNN version
if let Ok(cudnn_path) = std::env::var("CUDNN_PATH") {
info.insert("cudnn_path".to_string(), Value::Str(cudnn_path));
}
// Check for ROCm/HIP as alternative
// hipcc's output was recorded without an exit-status check, as nvcc's was.
let mut cmd = crate::safety::child_command("hipcc");
cmd.arg("--version");
if let Some(ver) = probe_text_if_installed("platform_cuda_version", "hipcc", cmd)? {
if let Some(line) = ver.lines().next() {
info.insert("hip".to_string(), Value::Str(line.to_string()));
}
}
if info.is_empty() {
Ok(Value::Null)
} else {
Ok(Value::Record(info))
}
}
// ============================================================================
// Platform Hardware Detection
// ============================================================================
fn bi_platform_cpu(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut info = std::collections::BTreeMap::new();
#[cfg(target_os = "windows")]
{
// Windows: was a PowerShell Get-CimInstance Win32_Processor query (WMI),
// parsed from its JSON. Now read in process: the name and physical core
// count through sysinfo; the logical count from available_parallelism
// (the count sys_cpu_count gives, not WMI's NumberOfLogicalProcessors);
// and the clock from the registry's ~MHz value (the boot-time reading,
// not WMI's rated MaxClockSpeed). A value that cannot be read is left out.
let sys = System::new_with_specifics(
sysinfo::RefreshKind::nothing().with_cpu(sysinfo::CpuRefreshKind::nothing()),
);
if let Some(cpu) = sys.cpus().first() {
let name = cpu.brand().trim();
if !name.is_empty() {
info.insert("name".to_string(), Value::Str(name.to_string()));
}
}
if let Some(cores) = sys.physical_core_count() {
info.insert("cores".to_string(), Value::Int(cores as i64));
}
if let Ok(lp) = std::thread::available_parallelism() {
info.insert(
"logical_processors".to_string(),
Value::Int(lp.get() as i64),
);
}
if let Ok(Some(mhz)) = windows_cpu_mhz("platform_cpu") {
info.insert("max_clock_mhz".to_string(), Value::Int(mhz as i64));
}
// Also get architecture
if let Ok(arch) = std::env::var("PROCESSOR_ARCHITECTURE") {
info.insert("arch".to_string(), Value::Str(arch));
}
}
#[cfg(target_os = "linux")]
{
if let Ok(cpuinfo) = std::fs::read_to_string("/proc/cpuinfo") {
for line in cpuinfo.lines() {
if let Some((key, val)) = line.split_once(':') {
let key = key.trim();
let val = val.trim();
match key {
"model name" => {
info.insert("name".to_string(), Value::Str(val.to_string()));
}
"cpu cores" => {
if let Ok(n) = val.parse::<i64>() {
info.insert("cores".to_string(), Value::Int(n));
}
}
"siblings" => {
if let Ok(n) = val.parse::<i64>() {
info.insert("logical_processors".to_string(), Value::Int(n));
}
}
"cpu MHz" => {
if let Ok(f) = val.parse::<f64>() {
info.insert("current_mhz".to_string(), Value::Float(f));
}
}
_ => {}
}
}
}
}
info.insert(
"arch".to_string(),
Value::Str(std::env::consts::ARCH.to_string()),
);
}
#[cfg(target_os = "macos")]
{
// A failed sysctl used to put its empty stdout in as `name` (""), or
// drop the count silently. Each probe is an error now if it fails.
let mut cmd = crate::safety::child_command("sysctl");
cmd.args(["-n", "machdep.cpu.brand_string"]);
let name = probe_text("platform_cpu", "sysctl", cmd)?;
info.insert("name".to_string(), Value::Str(name));
let mut cmd = crate::safety::child_command("sysctl");
cmd.args(["-n", "hw.physicalcpu"]);
let cores = probe_text("platform_cpu", "sysctl", cmd)?;
let cores = cores.parse::<i64>().map_err(|e| {
crate::safety::bad_state(
"platform_cpu",
&format!("hw.physicalcpu is not a number: {e}"),
"a sysctl that reports the physical CPU count",
)
})?;
info.insert("cores".to_string(), Value::Int(cores));
let mut cmd = crate::safety::child_command("sysctl");
cmd.args(["-n", "hw.logicalcpu"]);
let logical = probe_text("platform_cpu", "sysctl", cmd)?;
let logical = logical.parse::<i64>().map_err(|e| {
crate::safety::bad_state(
"platform_cpu",
&format!("hw.logicalcpu is not a number: {e}"),
"a sysctl that reports the logical CPU count",
)
})?;
info.insert("logical_processors".to_string(), Value::Int(logical));
info.insert(
"arch".to_string(),
Value::Str(std::env::consts::ARCH.to_string()),
);
}
Ok(Value::Record(info))
}
fn bi_platform_cpu_count(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// Use std::thread::available_parallelism for logical CPUs. A failure used to
// answer 1, which is a real count; it is an error now.
match std::thread::available_parallelism() {
Ok(count) => Ok(Value::Int(count.get() as i64)),
Err(e) => Err(crate::safety::bad_state(
"platform_cpu_count",
&format!("the CPU count could not be read: {e}"),
"a host that reports its available parallelism",
)),
}
}
fn bi_platform_cpu_freq(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// MHz as a Float on every OS. It was an Int on Windows and macOS and a
// Float on Linux, so the same call had two types. Null where the OS
// reports no frequency (Apple Silicon, many ARM and virtual CPUs).
#[cfg(target_os = "windows")]
{
// Null on an unreadable value, as Null was the answer to a failed
// PowerShell query before.
if let Ok(Some(mhz)) = windows_cpu_mhz("platform_cpu_freq") {
return Ok(Value::Float(mhz as f64));
}
}
#[cfg(target_os = "linux")]
{
if let Ok(cpuinfo) = std::fs::read_to_string("/proc/cpuinfo") {
for line in cpuinfo.lines() {
if line.starts_with("cpu MHz") {
if let Some((_, val)) = line.split_once(':') {
if let Ok(f) = val.trim().parse::<f64>() {
return Ok(Value::Float(f));
}
}
}
}
}
}
#[cfg(target_os = "macos")]
{
let mut cmd = crate::safety::child_command("sysctl");
cmd.args(["-n", "hw.cpufrequency"]);
// A sysctl that exits non-zero has no hw.cpufrequency (Apple Silicon),
// which is the Null below. probe_text checks the exit status.
if let Ok(text) = probe_text("platform_cpu_freq", "sysctl", cmd) {
if let Ok(n) = text.parse::<i64>() {
return Ok(Value::Float(n as f64 / 1_000_000.0)); // Hz to MHz
}
}
}
Ok(Value::Null)
}
fn bi_platform_memory(_args: Vec<Value>, input: Option<Value>) -> Result<Value> {
platform_refuse_input("platform_memory", &input)?;
let mut info = std::collections::BTreeMap::new();
// A probe that fails is a coded error. Each one used to be skipped, so a
// failed query answered {} -- indistinguishable from "this host has no
// memory to report".
// Was WMI Win32_OperatingSystem through PowerShell; now sysinfo, in process.
// The old "free" was WMI's FreePhysicalMemory, and sysinfo's available_memory
// counts standby memory too, so free_kb can read higher than the old figure.
#[cfg(target_os = "windows")]
{
let system = memory_snapshot("platform_memory")?;
let total = system.total_memory();
info.insert("total_kb".to_string(), Value::Int((total / 1024) as i64));
info.insert(
"total_gb".to_string(),
Value::Float(total as f64 / 1024.0 / 1024.0 / 1024.0),
);
let free = system.available_memory();
info.insert("free_kb".to_string(), Value::Int((free / 1024) as i64));
info.insert(
"free_gb".to_string(),
Value::Float(free as f64 / 1024.0 / 1024.0 / 1024.0),
);
}
#[cfg(target_os = "linux")]
{
let meminfo = std::fs::read_to_string("/proc/meminfo")
.map_err(|e| crate::safety::fs_error("platform_memory", "/proc/meminfo", &e))?;
{
for line in meminfo.lines() {
if let Some((key, val)) = line.split_once(':') {
let key = key.trim();
let val = val.trim().replace(" kB", "");
if let Ok(kb) = val.parse::<i64>() {
match key {
"MemTotal" => {
info.insert("total_kb".to_string(), Value::Int(kb));
info.insert(
"total_gb".to_string(),
Value::Float(kb as f64 / 1024.0 / 1024.0),
);
}
"MemFree" => {
info.insert("free_kb".to_string(), Value::Int(kb));
}
"MemAvailable" => {
info.insert("available_kb".to_string(), Value::Int(kb));
info.insert(
"available_gb".to_string(),
Value::Float(kb as f64 / 1024.0 / 1024.0),
);
}
"Buffers" => {
info.insert("buffers_kb".to_string(), Value::Int(kb));
}
"Cached" => {
info.insert("cached_kb".to_string(), Value::Int(kb));
}
"SwapTotal" => {
info.insert("swap_total_kb".to_string(), Value::Int(kb));
}
"SwapFree" => {
info.insert("swap_free_kb".to_string(), Value::Int(kb));
}
_ => {}
}
}
}
}
}
}
#[cfg(target_os = "macos")]
{
let mut cmd = crate::safety::child_command("sysctl");
cmd.args(["-n", "hw.memsize"]);
let out = bounded_cmd_output("platform_memory", "sysctl", cmd, PROBE_DEADLINE_SECS)
.map_err(|e| {
crate::safety::tool_missing("platform_memory", "sysctl", &e.to_string())
})?;
if !out.status.success() {
return Err(crate::safety::tool_failed(
"platform_memory",
"sysctl -n hw.memsize",
out.status.code(),
&String::from_utf8_lossy(&out.stderr),
));
}
let bytes = String::from_utf8_lossy(&out.stdout)
.trim()
.parse::<i64>()
.map_err(|e| {
crate::safety::tool_failed(
"platform_memory",
"sysctl -n hw.memsize",
out.status.code(),
&format!("it did not print a byte count: {e}"),
)
})?;
info.insert("total_bytes".to_string(), Value::Int(bytes));
info.insert(
"total_gb".to_string(),
Value::Float(bytes as f64 / 1024.0 / 1024.0 / 1024.0),
);
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
return Err(crate::safety::unimplemented(
"platform_memory",
"memory detection is not implemented for this operating system",
"read the memory total from the operating system directly",
));
}
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
{
if !info.contains_key("total_kb") && !info.contains_key("total_bytes") {
return Err(crate::safety::tool_failed(
"platform_memory",
"the platform memory query",
None,
"it reported no total memory",
));
}
}
Ok(Value::Record(info))
}
fn bi_platform_memory_total(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// Was WMI Win32_OperatingSystem through PowerShell; now sysinfo, in process.
// Returns bytes, as before.
#[cfg(target_os = "windows")]
{
let system = memory_snapshot("platform_memory_total")?;
return memory_bytes("platform_memory_total", system.total_memory());
}
#[cfg(target_os = "linux")]
{
if let Ok(meminfo) = std::fs::read_to_string("/proc/meminfo") {
for line in meminfo.lines() {
if line.starts_with("MemTotal:") {
let val = line.replace("MemTotal:", "").replace(" kB", "");
if let Ok(kb) = val.trim().parse::<i64>() {
return Ok(Value::Int(kb * 1024));
}
}
}
}
}
#[cfg(target_os = "macos")]
{
let mut cmd = crate::safety::child_command("sysctl");
cmd.args(["-n", "hw.memsize"]);
// probe_text refuses a non-zero exit with its code and stderr.
let text = probe_text("platform_memory_total", "sysctl", cmd)?;
if let Ok(bytes) = text.parse::<i64>() {
return Ok(Value::Int(bytes));
}
}
// Every machine has memory; reaching here means detection failed, which a
// null reported as an answer.
#[allow(unreachable_code)]
Err(crate::safety::tool_failed(
"platform_memory_total",
"the platform's memory query",
None,
"total memory could not be read",
))
}
fn bi_platform_memory_free(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// Available memory in bytes, the same quantity on every OS (sysinfo).
// This read MemAvailable on Linux but only the free-page list on macOS,
// and assumed 4096-byte pages there -- Apple Silicon's are 16384, so it
// reported a quarter of the free memory.
let mut sys = System::new();
sys.refresh_memory();
Ok(Value::Int(sys.available_memory() as i64))
}
fn bi_platform_disks(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut disks = Vec::new();
// This was `Get-CimInstance Win32_LogicalDisk` through PowerShell. It now
// reads the same volumes from kernel32. WMI listed every logical disk
// with a root, CD-ROM, network, removable and RAM-disk ones included, so
// this lists every volume GetDriveTypeW gives a root to. Each field WMI
// left empty for a drive is left out when its call fails: `size_*` and
// `free_*` when GetDiskFreeSpaceExW fails, `filesystem` when
// GetVolumeInformationW fails or the name is empty, and `label` when
// GetVolumeInformationW fails. An unlabeled volume keeps `label` as "",
// which is what WMI reported.
#[cfg(target_os = "windows")]
{
for v in windows_volumes("platform_disks")? {
let mut disk = std::collections::BTreeMap::new();
disk.insert("mount".to_string(), Value::Str(format!("{}:", v.letter)));
if let Some(size) = v.total {
disk.insert("size_bytes".to_string(), Value::Int(size as i64));
disk.insert(
"size_gb".to_string(),
Value::Float(size as f64 / 1024.0 / 1024.0 / 1024.0),
);
}
if let Some(free) = v.free {
disk.insert("free_bytes".to_string(), Value::Int(free as i64));
disk.insert(
"free_gb".to_string(),
Value::Float(free as f64 / 1024.0 / 1024.0 / 1024.0),
);
}
if let Some(fs) = v.fs_name.filter(|s| !s.is_empty()) {
disk.insert("filesystem".to_string(), Value::Str(fs));
}
if let Some(label) = v.label {
disk.insert("label".to_string(), Value::Str(label));
}
disks.push(Value::Record(disk));
}
}
#[cfg(target_os = "linux")]
{
let mut cmd = crate::safety::child_command("df");
cmd.args(["-B1", "--output=source,target,fstype,size,avail"]);
// A failed df used to list no disks. Its exit status is checked now.
let output = probe_text("platform_disks", "df", cmd)?;
for line in output.lines().skip(1) {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 5
&& !parts[0].starts_with("tmpfs")
&& !parts[0].starts_with("devtmpfs")
{
let mut disk = std::collections::BTreeMap::new();
disk.insert("device".to_string(), Value::Str(parts[0].to_string()));
disk.insert("mount".to_string(), Value::Str(parts[1].to_string()));
disk.insert("filesystem".to_string(), Value::Str(parts[2].to_string()));
if let Ok(n) = parts[3].parse::<i64>() {
disk.insert("size_bytes".to_string(), Value::Int(n));
disk.insert(
"size_gb".to_string(),
Value::Float(n as f64 / 1024.0 / 1024.0 / 1024.0),
);
}
if let Ok(n) = parts[4].parse::<i64>() {
disk.insert("free_bytes".to_string(), Value::Int(n));
disk.insert(
"free_gb".to_string(),
Value::Float(n as f64 / 1024.0 / 1024.0 / 1024.0),
);
}
disks.push(Value::Record(disk));
}
}
}
#[cfg(target_os = "macos")]
{
let mut cmd = crate::safety::child_command("df");
cmd.args(["-b"]);
// A failed df used to list no disks. Its exit status is checked now.
let output = probe_text("platform_disks", "df", cmd)?;
for line in output.lines().skip(1) {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 9 && parts[0].starts_with("/dev/") {
let mut disk = std::collections::BTreeMap::new();
disk.insert("device".to_string(), Value::Str(parts[0].to_string()));
disk.insert("mount".to_string(), Value::Str(parts[8].to_string()));
if let Ok(n) = parts[1].parse::<i64>() {
let bytes = n * 512;
disk.insert("size_bytes".to_string(), Value::Int(bytes));
disk.insert(
"size_gb".to_string(),
Value::Float(bytes as f64 / 1024.0 / 1024.0 / 1024.0),
);
}
if let Ok(n) = parts[3].parse::<i64>() {
let bytes = n * 512;
disk.insert("free_bytes".to_string(), Value::Int(bytes));
disk.insert(
"free_gb".to_string(),
Value::Float(bytes as f64 / 1024.0 / 1024.0 / 1024.0),
);
}
disks.push(Value::Record(disk));
}
}
}
Ok(Value::Array(disks))
}
fn bi_platform_disk_usage(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// In-process, from the disk that holds `path`. This ran `df -B1`, which
// BSD df does not accept, so on macOS it always answered null; on
// Windows it ignored any path without a drive letter and answered {} for
// a drive that did not exist.
let path = args.first().and_then(|v| v.as_str().ok()).unwrap_or(".");
let resolved = std::fs::canonicalize(path)
.map_err(|e| crate::safety::fs_error("platform_disk_usage", path, &e))?;
// Windows canonical paths carry the verbatim prefix, backslash backslash
// question-mark backslash, which no mount point such as C: is a prefix
// of. Spelled with escapes, not a raw string, and described in words
// here: the source scanners in tests/ do not skip comments, and a quote
// after a backslash reads to them as an escaped quote that never closes.
let resolved = match resolved.to_str().and_then(|s| s.strip_prefix("\\\\?\\")) {
Some(rest) if !rest.starts_with("UNC") => std::path::PathBuf::from(rest),
_ => resolved,
};
let disks = Disks::new_with_refreshed_list();
let disk = disks
.iter()
.filter(|d| resolved.starts_with(d.mount_point()))
.max_by_key(|d| d.mount_point().as_os_str().len())
.ok_or_else(|| {
crate::safety::bad_state(
"platform_disk_usage",
&format!("no mounted disk contains {}", resolved.display()),
"pass a path on a mounted filesystem",
)
})?;
let total = disk.total_space() as i64;
let free = disk.available_space() as i64;
let used = total - free;
let mut info = BTreeMap::new();
info.insert(
"mount_point".to_string(),
Value::Str(disk.mount_point().to_string_lossy().to_string()),
);
info.insert("total_bytes".to_string(), Value::Int(total));
info.insert("free_bytes".to_string(), Value::Int(free));
info.insert("used_bytes".to_string(), Value::Int(used));
info.insert(
"usage_percent".to_string(),
Value::Float(if total > 0 {
used as f64 / total as f64 * 100.0
} else {
0.0
}),
);
Ok(Value::Record(info))
}
/// The AMD GPUs in `rocm-smi` text, one record per `GPU[n]` index, with the
/// keys the NVIDIA rows use: vendor, index, name, memory_mb, memory_free_mb,
/// driver, temperature_c, utilization_percent. rocm-smi prints one
/// `GPU[n] : label: value` line per field. A field it does not print is Null
/// (driver is never printed under these flags). Lines that are not GPU fields
/// are skipped.
#[cfg(target_os = "linux")]
fn rocm_smi_records(text: &str) -> Vec<Value> {
let mut by_index: BTreeMap<i64, BTreeMap<String, String>> = BTreeMap::new();
for line in text.lines() {
let Some(rest) = line.trim().strip_prefix("GPU[") else {
continue;
};
let Some((idx, field)) = rest.split_once(']') else {
continue;
};
let Ok(index) = idx.trim().parse::<i64>() else {
continue;
};
let field = field.trim().trim_start_matches(':').trim();
let Some((label, value)) = field.split_once(':') else {
continue;
};
by_index
.entry(index)
.or_default()
.insert(label.trim().to_string(), value.trim().to_string());
}
by_index
.into_iter()
.map(|(index, fields)| {
let int = |label: &str| fields.get(label).and_then(|v| v.parse::<i64>().ok());
let float = |label: &str| {
fields
.get(label)
.and_then(|v| v.parse::<f64>().ok())
.map(|f| Value::Int(f.round() as i64))
.unwrap_or(Value::Null)
};
let total_bytes = int("VRAM Total Memory (B)");
let used_bytes = int("VRAM Total Used Memory (B)");
let name = fields
.get("Card series")
.filter(|v| !v.is_empty())
.cloned()
.map(Value::Str)
.unwrap_or(Value::Null);
let memory_free_mb = match (total_bytes, used_bytes) {
(Some(total), Some(used)) => Value::Int((total - used) / 1024 / 1024),
_ => Value::Null,
};
let mut gpu = BTreeMap::new();
gpu.insert("vendor".to_string(), Value::Str("AMD".to_string()));
gpu.insert("index".to_string(), Value::Int(index));
gpu.insert("name".to_string(), name);
gpu.insert(
"memory_mb".to_string(),
total_bytes
.map(|b| Value::Int(b / 1024 / 1024))
.unwrap_or(Value::Null),
);
gpu.insert("memory_free_mb".to_string(), memory_free_mb);
gpu.insert("driver".to_string(), Value::Null);
gpu.insert(
"temperature_c".to_string(),
float("Temperature (Sensor edge) (C)"),
);
gpu.insert("utilization_percent".to_string(), float("GPU use (%)"));
Value::Record(gpu)
})
.collect()
}
fn bi_platform_gpus(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut gpus = Vec::new();
// NVIDIA GPUs via nvidia-smi. Not installed means no NVIDIA GPUs. Installed
// but failing is an error: it used to read as none. A row with too few
// columns is also an error. A single field that is not a number (nvidia-smi
// prints [N/A] for some) stays absent, as before.
let mut cmd = crate::safety::child_command("nvidia-smi");
cmd.args(["--query-gpu=index,name,memory.total,memory.free,driver_version,temperature.gpu,utilization.gpu", "--format=csv,noheader,nounits"]);
match bounded_cmd_output("platform_gpus", "nvidia-smi", cmd, PROBE_DEADLINE_SECS) {
Ok(out) => {
let output = probe_stdout("platform_gpus", "nvidia-smi", out)?;
for line in output.lines() {
let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
if parts.len() < 7 {
return Err(crate::safety::bad_state(
"platform_gpus",
&format!(
"nvidia-smi row has {} columns, expected 7: {line}",
parts.len()
),
"the csv query nvidia-smi was asked for",
));
}
let mut gpu = std::collections::BTreeMap::new();
gpu.insert("vendor".to_string(), Value::Str("NVIDIA".to_string()));
if let Ok(idx) = parts[0].parse::<i64>() {
gpu.insert("index".to_string(), Value::Int(idx));
}
gpu.insert("name".to_string(), Value::Str(parts[1].to_string()));
if let Ok(mem) = parts[2].parse::<i64>() {
gpu.insert("memory_mb".to_string(), Value::Int(mem));
}
if let Ok(free) = parts[3].parse::<i64>() {
gpu.insert("memory_free_mb".to_string(), Value::Int(free));
}
gpu.insert("driver".to_string(), Value::Str(parts[4].to_string()));
if let Ok(temp) = parts[5].parse::<i64>() {
gpu.insert("temperature_c".to_string(), Value::Int(temp));
}
if let Ok(util) = parts[6].parse::<i64>() {
gpu.insert("utilization_percent".to_string(), Value::Int(util));
}
gpus.push(Value::Record(gpu));
}
}
Err(e) if is_tool_missing(&e) => {}
Err(e) => return Err(e),
}
// AMD GPUs via rocm-smi (Linux), in the same record shape as the NVIDIA rows.
#[cfg(target_os = "linux")]
if gpus.is_empty() {
let mut cmd = crate::safety::child_command("rocm-smi");
cmd.args([
"--showproductname",
"--showmeminfo",
"vram",
"--showtemp",
"--showuse",
]);
if let Ok(out) = bounded_cmd_output("platform_gpus", "rocm-smi", cmd, PROBE_DEADLINE_SECS) {
if out.status.success() {
let output = String::from_utf8_lossy(&out.stdout);
gpus.extend(rocm_smi_records(&output));
}
}
}
// Fallback: Windows. wmic is absent on current Windows 11, so this uses the
// same CIM plus registry probe as hw_gpu. The 64-bit driver size is reported.
#[cfg(target_os = "windows")]
if gpus.is_empty() {
if let Ok(Some(adapters)) = win_gpu_probe("platform_gpus") {
for g in adapters {
let mut rec = std::collections::BTreeMap::new();
if let Some(name) = g.name {
rec.insert("name".to_string(), Value::Str(name));
}
if let Some(bytes) = g.vram_bytes {
rec.insert("memory_bytes".to_string(), Value::Int(bytes));
rec.insert("memory_mb".to_string(), Value::Int(bytes / 1024 / 1024));
}
if let Some(driver) = g.driver {
rec.insert("driver".to_string(), Value::Str(driver));
}
gpus.push(Value::Record(rec));
}
}
}
Ok(Value::Array(gpus))
}
/// Sums the rows nvidia-smi prints for `--query-gpu=memory.total,memory.used,memory.free
/// --format=csv,noheader,nounits` (one row per GPU) into `{total_mb, used_mb, free_mb,
/// devices}`. A row that is not three numbers is refused, not skipped: skipping it would
/// total fewer GPUs than the host has. No rows is refused too.
fn gpu_memory_record(output: &str) -> Result<Value> {
let mut totals = [0i64; 3];
let mut devices = 0i64;
for line in output.lines().map(str::trim).filter(|l| !l.is_empty()) {
let parts: Vec<&str> = line.split(',').map(str::trim).collect();
if parts.len() != 3 {
return Err(crate::safety::bad_state(
"platform_gpu_memory",
&format!("nvidia-smi memory row is not total,used,free: {line:?}"),
"the csv query nvidia-smi was asked for",
));
}
for (i, text) in parts.iter().enumerate() {
let value = text.parse::<i64>().map_err(|e| {
crate::safety::bad_state(
"platform_gpu_memory",
&format!("nvidia-smi memory field {i} is not a number: {e}"),
"the csv query nvidia-smi was asked for",
)
})?;
totals[i] = totals[i].saturating_add(value);
}
devices += 1;
}
if devices == 0 {
return Err(crate::safety::bad_state(
"platform_gpu_memory",
"nvidia-smi printed no memory rows",
"the csv query nvidia-smi was asked for",
));
}
let mut info = BTreeMap::new();
info.insert("total_mb".to_string(), Value::Int(totals[0]));
info.insert("used_mb".to_string(), Value::Int(totals[1]));
info.insert("free_mb".to_string(), Value::Int(totals[2]));
info.insert("devices".to_string(), Value::Int(devices));
Ok(Value::Record(info))
}
fn bi_platform_gpu_memory(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut cmd = crate::safety::child_command("nvidia-smi");
cmd.args([
"--query-gpu=memory.total,memory.used,memory.free",
"--format=csv,noheader,nounits",
]);
// Not installed is no figures (Null). Installed but failing, or output that
// does not hold three numbers, is an error. Both used to read as Null.
let out = match bounded_cmd_output(
"platform_gpu_memory",
"nvidia-smi",
cmd,
PROBE_DEADLINE_SECS,
) {
Ok(out) => out,
Err(e) if is_tool_missing(&e) => return Ok(Value::Null),
Err(e) => return Err(e),
};
let output = probe_stdout("platform_gpu_memory", "nvidia-smi", out)?;
gpu_memory_record(&output)
}
fn bi_platform_network_interfaces(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut interfaces = Vec::new();
#[cfg(target_os = "windows")]
{
// The adapter table, in process. This was `wmic nic`, which Windows
// 11 no longer ships; without it the answer was one record holding
// ipconfig's raw text instead of a list of interfaces. The records
// have the Linux branch's fields: name, state, mac and addresses.
for adapter in windows_adapters("platform_network_interfaces")? {
let mut nic = std::collections::BTreeMap::new();
nic.insert("name".to_string(), Value::Str(adapter.name));
nic.insert(
"state".to_string(),
Value::Str(if adapter.up { "UP" } else { "DOWN" }.to_string()),
);
if let Some(mac) = adapter.mac {
nic.insert("mac".to_string(), Value::Str(mac));
}
let ips: Vec<Value> = adapter
.addresses
.iter()
.map(|ip| Value::Str(ip.to_string()))
.collect();
if !ips.is_empty() {
nic.insert("addresses".to_string(), Value::Array(ips));
}
interfaces.push(Value::Record(nic));
}
}
#[cfg(target_os = "linux")]
{
let mut cmd = crate::safety::child_command("ip");
cmd.args(["--json", "addr"]);
if let Ok(out) = bounded_cmd_output(
"platform_network_interfaces",
"ip",
cmd,
PROBE_DEADLINE_SECS,
) {
if out.status.success() {
if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&out.stdout) {
if let Some(arr) = json.as_array() {
for iface in arr {
let mut nic = std::collections::BTreeMap::new();
if let Some(name) = iface.get("ifname").and_then(|v| v.as_str()) {
nic.insert("name".to_string(), Value::Str(name.to_string()));
}
if let Some(state) = iface.get("operstate").and_then(|v| v.as_str()) {
nic.insert("state".to_string(), Value::Str(state.to_string()));
}
if let Some(mac) = iface.get("address").and_then(|v| v.as_str()) {
nic.insert("mac".to_string(), Value::Str(mac.to_string()));
}
if let Some(addrs) = iface.get("addr_info").and_then(|v| v.as_array()) {
let ips: Vec<Value> = addrs
.iter()
.filter_map(|a| a.get("local").and_then(|v| v.as_str()))
.map(|s| Value::Str(s.to_string()))
.collect();
if !ips.is_empty() {
nic.insert("addresses".to_string(), Value::Array(ips));
}
}
interfaces.push(Value::Record(nic));
}
}
}
}
}
}
#[cfg(target_os = "macos")]
{
let cmd = crate::safety::child_command("ifconfig");
if let Ok(out) = bounded_cmd_output(
"platform_network_interfaces",
"ifconfig",
cmd,
PROBE_DEADLINE_SECS,
) {
let output = String::from_utf8_lossy(&out.stdout);
let mut current_name = String::new();
let mut current = std::collections::BTreeMap::new();
for line in output.lines() {
if !line.starts_with('\t') && !line.starts_with(' ') && line.contains(':') {
if !current_name.is_empty() && !current.is_empty() {
current.insert("name".to_string(), Value::Str(current_name.clone()));
interfaces.push(Value::Record(current.clone()));
}
current.clear();
current_name = line.split(':').next().unwrap_or("").to_string();
} else if line.contains("ether ") {
let mac = line
.split("ether ")
.nth(1)
.unwrap_or("")
.split_whitespace()
.next()
.unwrap_or("");
current.insert("mac".to_string(), Value::Str(mac.to_string()));
} else if line.contains("inet ") && !line.contains("inet6") {
let ip = line
.split("inet ")
.nth(1)
.unwrap_or("")
.split_whitespace()
.next()
.unwrap_or("");
current.insert("ipv4".to_string(), Value::Str(ip.to_string()));
}
}
if !current_name.is_empty() && !current.is_empty() {
current.insert("name".to_string(), Value::Str(current_name));
interfaces.push(Value::Record(current));
}
}
}
Ok(Value::Array(interfaces))
}
fn bi_platform_hardware_summary(_args: Vec<Value>, input: Option<Value>) -> Result<Value> {
platform_refuse_input("platform_hardware_summary", &input)?;
let mut summary = std::collections::BTreeMap::new();
let mut unavailable = Vec::new();
// A section that cannot be read is listed under `unavailable` with its
// coded reason. It used to be dropped, so the summary looked complete.
let sections: [(&str, Result<Value>); 5] = [
("cpu", bi_platform_cpu(vec![], None)),
("memory", bi_platform_memory(vec![], None)),
("disks", bi_platform_disks(vec![], None)),
("gpus", bi_platform_gpus(vec![], None)),
("network", bi_platform_network_interfaces(vec![], None)),
];
for (name, result) in sections {
match result {
// An empty list of GPUs or interfaces is an answer, not a section to show.
Ok(Value::Array(items)) if items.is_empty() && matches!(name, "gpus" | "network") => {}
Ok(value) => {
summary.insert(name.to_string(), value);
}
Err(e) => unavailable.push(Value::Str(format!("{name}: {e}"))),
}
}
if !unavailable.is_empty() {
summary.insert("unavailable".to_string(), Value::Array(unavailable));
}
// Add architecture and OS
summary.insert(
"arch".to_string(),
Value::Str(std::env::consts::ARCH.to_string()),
);
summary.insert(
"os".to_string(),
Value::Str(std::env::consts::OS.to_string()),
);
Ok(Value::Record(summary))
}
// ============================================================================
// Container Management Builtins (816-849)
// Docker, Podman, and runtime-agnostic container operations
// ============================================================================
// ---------------------------------------------------------------------------
// Helper: run a container CLI command and parse JSON-per-line output
// ---------------------------------------------------------------------------
// Every container builtin runs its tool with an argv and never through a shell.
// Stdout is capped at CONTAINER_OUTPUT_MAX bytes: an answer that does not fit
// is a budget error, never a truncated value. Stderr is kept as a short tail for
// the failure message, or appended to the answer when `with_stderr` is set,
// because build, pull, push and compose write their progress there and `logs`
// carries the container's own stderr there.
// ---------------------------------------------------------------------------
const CONTAINER_OUTPUT_MAX: usize = 4 * 1024 * 1024;
const CONTAINER_STDERR_TAIL: usize = 4 * 1024;
const CONTAINER_LOG_TAIL_DEFAULT: i64 = 1000;
const CONTAINER_LOG_TAIL_MAX: i64 = 10_000;
const CONTAINER_ARGV_MAX: usize = 256;
/// Deadline for one container CLI call; past it the call's tree is killed.
const CONTAINER_DEADLINE_SECS: u64 = 300;
/// A coded `E_TOOL_FAILED` for a container tool whose output cannot be read.
fn container_error(builtin: &str, message: String, hint: &str, got: &str) -> anyhow::Error {
anyhow::Error::new(crate::safety::SafetyError {
code: crate::safety::ErrorCode::ToolFailed,
message: format!("{builtin}: {message}"),
builtin: builtin.to_string(),
hint: hint.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: got.to_string(),
})
}
/// Run `program` with `args` as an argv, never through a shell, and return its
/// stdout (followed by its stderr when `with_stderr` is set). A nonzero exit is
/// `E_TOOL_FAILED` carrying the stderr tail; a missing program is
/// `E_TOOL_MISSING`; output over the bound is `E_BUDGET_EXCEEDED`.
fn container_cli(builtin: &str, program: &str, args: &[&str], with_stderr: bool) -> Result<String> {
container_cli_within(builtin, program, args, with_stderr, CONTAINER_DEADLINE_SECS)
}
/// [`container_cli`] with its deadline given, so a test can run a silent child
/// past a short one. The call's tree is killed at `deadline_secs`.
fn container_cli_within(
builtin: &str,
program: &str,
args: &[&str],
with_stderr: bool,
deadline_secs: u64,
) -> Result<String> {
let verb = args.first().copied().unwrap_or("");
let command = format!("{program} {verb}");
let stderr_keep = if with_stderr {
Keep::Head(CONTAINER_OUTPUT_MAX)
} else {
Keep::Tail(CONTAINER_STDERR_TAIL)
};
let run = run_tree(
program,
args,
|_| {},
None,
std::time::Duration::from_secs(deadline_secs),
Keep::Head(CONTAINER_OUTPUT_MAX),
stderr_keep,
)
.map_err(|e| match e {
TreeError::Spawn(e) => crate::safety::spawn_error(builtin, program, &e),
TreeError::Wait(e) => container_error(
builtin,
format!("could not wait for `{command}`: {e}"),
"retry the call",
"no exit status",
),
})?;
if run.timed_out {
return Err(crate::safety::budget_exceeded(
builtin,
format!("`{command}` did not finish within {deadline_secs} s"),
"the call was killed and its result discarded; check the state with the matching read builtin before retrying",
));
}
// A stderr tail is kept short on purpose; only a stream returned whole
// (stdout, or stderr when it is appended) can be over budget.
if run.stdout_truncated || (with_stderr && run.stderr_truncated) {
return Err(crate::safety::budget_exceeded(
builtin,
format!("`{command}` wrote more than {CONTAINER_OUTPUT_MAX} bytes"),
"ask for less output: a smaller tail, or one container at a time",
));
}
if !run.success {
let stderr = &run.stderr;
let tail = &stderr[stderr.len().saturating_sub(CONTAINER_STDERR_TAIL)..];
return Err(crate::safety::tool_failed(
builtin,
&command,
run.code,
&String::from_utf8_lossy(tail),
));
}
let stdout = run.stdout;
let stderr = run.stderr;
let mut text = String::from_utf8_lossy(&stdout).into_owned();
if with_stderr && !stderr.is_empty() {
if !text.is_empty() && !text.ends_with('\n') {
text.push('\n');
}
text.push_str(&String::from_utf8_lossy(&stderr));
}
Ok(text)
}
/// Parse a container CLI's listing: a JSON array, or one JSON object per line.
/// A line that does not parse is an error, never a dropped row.
fn container_rows(builtin: &str, tool: &str, text: &str) -> Result<Vec<Value>> {
let trimmed = text.trim();
let unparsed = || {
container_error(
builtin,
format!("the output of `{tool}` is not in a form this builtin reads"),
"the tool ran but printed output this builtin does not parse; check its version",
"unreadable output",
)
};
if trimmed.starts_with('[') {
let json: serde_json::Value = serde_json::from_str(trimmed).map_err(|_| unparsed())?;
return Ok(match json_to_value(json) {
Value::Array(rows) => rows,
other => vec![other],
});
}
let mut rows = Vec::new();
for line in trimmed.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let json: serde_json::Value = serde_json::from_str(line).map_err(|_| unparsed())?;
rows.push(json_to_value(json));
}
Ok(rows)
}
// Helper: run a container CLI command and parse JSON-per-line output
fn container_json_lines(program: &str, args: &[&str]) -> Result<Vec<Value>> {
let text = container_cli(program, program, args, false)?;
container_rows(program, program, &text)
}
// Helper: run a container CLI command and return stdout as a string
fn container_run_cmd(program: &str, args: &[&str]) -> Result<String> {
container_cli(program, program, args, false)
}
// ---------------------------------------------------------------------------
// Argument checks shared by the docker_* and podman_* builtins
// ---------------------------------------------------------------------------
fn refuse_pipeline(builtin: &str, input: &Option<Value>) -> Result<()> {
if input.is_some() {
return Err(crate::safety::arg_err(format!(
"{builtin} takes no piped input; pass its arguments directly"
)));
}
Ok(())
}
/// A container, image, tag or path operand: non-empty, free of control
/// characters, and never option-like. The CLI would read a leading '-' as a
/// flag, and `--` is placed before operands as well, so this is the second
/// guard, not the only one.
fn container_operand(builtin: &str, what: &str, value: &str) -> Result<()> {
if value.is_empty() {
return Err(crate::safety::bad_arg(
builtin,
&format!("{what}: a non-empty String"),
"an empty string",
));
}
if value.chars().any(char::is_control) {
return Err(crate::safety::bad_arg(
builtin,
&format!("{what}: a String without control characters"),
"a control character",
));
}
crate::safety::reject_option_like(builtin, &[value.to_string()])
}
fn str_operand<'a>(builtin: &str, what: &str, value: Option<&'a Value>) -> Result<&'a str> {
match value {
Some(Value::Str(s)) => Ok(s.as_str()),
Some(other) => Err(crate::safety::bad_arg(
builtin,
&format!("{what}: String"),
other.type_name(),
)),
None => Err(crate::safety::arg_err(format!(
"{builtin} requires {what} as its first argument"
))),
}
}
fn optional_bool(builtin: &str, what: &str, value: Option<&Value>) -> Result<bool> {
match value {
None => Ok(false),
Some(Value::Bool(b)) => Ok(*b),
Some(other) => Err(crate::safety::bad_arg(
builtin,
&format!("{what}: Bool"),
other.type_name(),
)),
}
}
/// An argv: an Array of Strings. A shell command line is refused, because it
/// would have to be split and interpreted; each element is one argument.
fn argv_arg(
builtin: &str,
what: &str,
value: Option<&Value>,
required: bool,
) -> Result<Vec<String>> {
let items = match value {
None if required => {
return Err(crate::safety::bad_arg(
builtin,
&format!("{what}: an Array of String (argv, not a shell string)"),
"nothing",
))
}
None => return Ok(Vec::new()),
Some(Value::Array(items)) => items,
Some(other) => {
return Err(crate::safety::bad_arg(
builtin,
&format!("{what}: an Array of String (argv, not a shell string)"),
other.type_name(),
))
}
};
if required && items.is_empty() {
return Err(crate::safety::bad_arg(
builtin,
&format!("{what}: at least one element"),
"an empty array",
));
}
if items.len() > CONTAINER_ARGV_MAX {
return Err(crate::safety::budget_exceeded(
builtin,
format!(
"{what} has {} elements; the limit is {CONTAINER_ARGV_MAX}",
items.len()
),
"pass fewer arguments",
));
}
items
.iter()
.map(|item| match item {
Value::Str(s) if s.contains('\0') => Err(crate::safety::bad_arg(
builtin,
&format!("{what}: Strings without NUL bytes"),
"a NUL byte",
)),
Value::Str(s) => Ok(s.clone()),
other => Err(crate::safety::bad_arg(
builtin,
&format!("{what}: an Array of String"),
other.type_name(),
)),
})
.collect()
}
/// `docker run` flags from a Record. A flag name is lowercase letters, digits
/// and hyphens, so it cannot smuggle a second option. A string value is passed
/// as `--flag=value`, a single token the CLI cannot read as anything else; the
/// few short flags take their value as the next token, which is option-checked.
/// Any value of another type is refused, not ignored.
fn container_run_flags(builtin: &str, flags: &BTreeMap<String, Value>) -> Result<Vec<String>> {
let mut argv = Vec::new();
for (key, value) in flags {
let well_formed = key.len() <= 64
&& key.starts_with(|c: char| c.is_ascii_lowercase())
&& key
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
if !well_formed {
return Err(crate::safety::bad_arg(
builtin,
"flag names of lowercase letters, digits and '-', starting with a letter",
key,
));
}
match (key.as_str(), value) {
(_, Value::Bool(false)) => {}
("detach", Value::Bool(true)) => argv.push("-d".to_string()),
("rm", Value::Bool(true)) => argv.push("--rm".to_string()),
("name" | "port" | "env" | "volume", Value::Str(s)) => {
container_operand(builtin, key, s)?;
let short = match key.as_str() {
"name" => "--name",
"port" => "-p",
"env" => "-e",
_ => "-v",
};
argv.push(short.to_string());
argv.push(s.clone());
}
(_, Value::Bool(true)) => argv.push(format!("--{key}")),
(_, Value::Str(s)) => argv.push(format!("--{key}={s}")),
(_, Value::Int(n)) => argv.push(format!("--{key}={n}")),
(_, other) => {
return Err(crate::safety::bad_arg(
builtin,
&format!("flag {key}: Bool, String or Int"),
other.type_name(),
))
}
}
}
Ok(argv)
}
/// A local path the builtin will read: refused if option-like, then resolved
/// against the workspace and guarded as a read, the same as `bat`.
fn guard_local_read(builtin: &str, path: &str) -> Result<String> {
crate::safety::reject_option_like(builtin, &[path.to_string()])?;
let path = crate::safety::resolve_path_str(path);
crate::safety::guard(crate::safety::GuardCtx {
builtin,
effect: crate::safety::Effect::ReadLocal,
what: "read",
targets: vec![path.clone()],
blast_radius: serde_json::json!({ "path": path }),
reversible: true,
fs_paths: true,
})?;
Ok(path)
}
/// An optional compose file: validated, then read-guarded and resolved.
fn compose_file_arg(builtin: &str, value: Option<&Value>) -> Result<Option<String>> {
match value {
None => Ok(None),
Some(Value::Str(file)) => {
container_operand(builtin, "compose file", file)?;
Ok(Some(guard_local_read(builtin, file)?))
}
Some(other) => Err(crate::safety::bad_arg(
builtin,
"compose file: String",
other.type_name(),
)),
}
}
// ---------------------------------------------------------------------------
// Generic container operations, parameterised by the CLI (docker or podman)
// ---------------------------------------------------------------------------
fn container_run(
program: &str,
builtin: &str,
args: &[Value],
input: &Option<Value>,
) -> Result<Value> {
refuse_pipeline(builtin, input)?;
let image = str_operand(builtin, "image", args.first())?;
container_operand(builtin, "image", image)?;
// `run` pulls the image when it is not present, from the registry the
// reference names (see `image_registry`), so that registry is judged here.
// A daemon or registries.conf mirror is judged by check_container_mirror_egress.
crate::safety::check_egress(builtin, &[format!("https://{}", image_registry(image))])?;
crate::safety::check_container_mirror_egress(builtin)?;
let argv = argv_arg(builtin, "command", args.get(1), false)?;
let flags = match args.get(2) {
None => Vec::new(),
Some(Value::Record(record)) => container_run_flags(builtin, record)?,
Some(other) => {
return Err(crate::safety::bad_arg(
builtin,
"flags: Record",
other.type_name(),
))
}
};
// Flags, then `--`, then the image and its argv: nothing after `--` is read
// as an option, so an argv element beginning with '-' reaches the container.
let mut cmd: Vec<&str> = vec!["run"];
cmd.extend(flags.iter().map(String::as_str));
cmd.push("--");
cmd.push(image);
cmd.extend(argv.iter().map(String::as_str));
let text = container_cli(builtin, program, &cmd, false)?;
Ok(Value::Str(text.trim().to_string()))
}
/// The container and argv of an exec call, checked. The caller runs the
/// policy guard and then `exec_run`.
fn exec_request(
builtin: &str,
args: &[Value],
input: &Option<Value>,
) -> Result<(String, Vec<String>)> {
refuse_pipeline(builtin, input)?;
let container = str_operand(builtin, "container", args.first())?;
container_operand(builtin, "container", container)?;
let argv = argv_arg(builtin, "command", args.get(1), true)?;
Ok((container.to_string(), argv))
}
fn exec_run(program: &str, builtin: &str, container: &str, argv: &[String]) -> Result<Value> {
let mut cmd: Vec<&str> = vec!["exec", "--", container];
cmd.extend(argv.iter().map(String::as_str));
let text = container_cli(builtin, program, &cmd, false)?;
Ok(Value::Str(text.trim().to_string()))
}
fn container_logs(
program: &str,
builtin: &str,
args: &[Value],
input: &Option<Value>,
) -> Result<Value> {
refuse_pipeline(builtin, input)?;
let container = str_operand(builtin, "container", args.first())?;
container_operand(builtin, "container", container)?;
// The tail is bounded so a long-lived container cannot fill the answer.
let tail = match args.get(1) {
None => CONTAINER_LOG_TAIL_DEFAULT,
Some(Value::Int(n)) if (1..=CONTAINER_LOG_TAIL_MAX).contains(n) => *n,
Some(Value::Int(n)) => {
return Err(crate::safety::bad_arg(
builtin,
&format!("tail: Int in 1..={CONTAINER_LOG_TAIL_MAX}"),
&n.to_string(),
))
}
Some(other) => {
return Err(crate::safety::bad_arg(
builtin,
&format!("tail: Int in 1..={CONTAINER_LOG_TAIL_MAX}"),
other.type_name(),
))
}
};
let tail = tail.to_string();
let text = container_cli(
builtin,
program,
&["logs", "--tail", tail.as_str(), "--", container],
true,
)?;
Ok(Value::Str(text))
}
fn container_stop(
program: &str,
builtin: &str,
args: &[Value],
input: &Option<Value>,
) -> Result<Value> {
refuse_pipeline(builtin, input)?;
let container = str_operand(builtin, "container", args.first())?;
container_operand(builtin, "container", container)?;
container_cli(builtin, program, &["stop", "--", container], false)?;
Ok(Value::Str(format!("Stopped container {container}")))
}
/// The container and force flag of a rm call, checked. Its policy guard, which
/// differs between the two CLIs, is the caller's.
fn rm_request(builtin: &str, args: &[Value], input: &Option<Value>) -> Result<(String, bool)> {
refuse_pipeline(builtin, input)?;
let container = str_operand(builtin, "container", args.first())?;
container_operand(builtin, "container", container)?;
let force = optional_bool(builtin, "force", args.get(1))?;
Ok((container.to_string(), force))
}
fn rm_run(program: &str, builtin: &str, container: &str, force: bool) -> Result<Value> {
let mut cmd = vec!["rm"];
if force {
cmd.push("-f");
}
cmd.push("--");
cmd.push(container);
container_cli(builtin, program, &cmd, false)?;
Ok(Value::Str(format!("Removed container {container}")))
}
fn container_build(
program: &str,
builtin: &str,
args: &[Value],
input: &Option<Value>,
) -> Result<Value> {
refuse_pipeline(builtin, input)?;
let tag = str_operand(builtin, "tag", args.first())?;
container_operand(builtin, "tag", tag)?;
let path = match args.get(1) {
None => ".",
Some(Value::Str(p)) => {
container_operand(builtin, "path", p)?;
p.as_str()
}
Some(other) => {
return Err(crate::safety::bad_arg(
builtin,
"path: String",
other.type_name(),
))
}
};
// A build pulls the base images its Dockerfile's FROM lines name, so the
// registries are in the build context, not in the arguments.
crate::safety::check_egress_unknown(
builtin,
"the base images are named in the build context's Dockerfile, not in the arguments",
)?;
// The build context is read, so it is resolved and guarded as a read.
let context = guard_local_read(builtin, path)?;
let text = container_cli(
builtin,
program,
&["build", "-t", tag, "--", context.as_str()],
true,
)?;
Ok(Value::Str(text))
}
/// The registry a container image reference names, by the rule the docker and
/// podman CLIs apply: the part before the first `/` is a registry when it holds a
/// `.` or a `:` or is `localhost`; otherwise the reference is on Docker Hub. A
/// digest (`@sha256:…`) is not part of the name. This names the registry the
/// reference asks for; a daemon configured with a mirror sends the request to the
/// mirror, which this does not see.
fn image_registry(image: &str) -> &str {
let name = image.split('@').next().unwrap_or(image);
match name.split_once('/') {
Some((first, _)) if first.contains('.') || first.contains(':') || first == "localhost" => {
first
}
_ => "docker.io",
}
}
/// `pull` and `push`: one image, one CLI verb. Both report progress on stderr.
fn container_image_op(
program: &str,
builtin: &str,
verb: &str,
args: &[Value],
input: &Option<Value>,
) -> Result<Value> {
refuse_pipeline(builtin, input)?;
let image = str_operand(builtin, "image", args.first())?;
container_operand(builtin, "image", image)?;
// The registry is the one the reference names; see `image_registry`. A mirror
// in the daemon or registries.conf is judged by check_container_mirror_egress.
crate::safety::check_egress(builtin, &[format!("https://{}", image_registry(image))])?;
crate::safety::check_container_mirror_egress(builtin)?;
let text = container_cli(builtin, program, &[verb, "--", image], true)?;
Ok(Value::Str(text))
}
fn container_tag(
program: &str,
builtin: &str,
args: &[Value],
input: &Option<Value>,
) -> Result<Value> {
refuse_pipeline(builtin, input)?;
let source = str_operand(builtin, "source image", args.first())?;
container_operand(builtin, "source image", source)?;
let target = str_operand(builtin, "target tag", args.get(1))?;
container_operand(builtin, "target tag", target)?;
container_cli(builtin, program, &["tag", "--", source, target], false)?;
Ok(Value::Str(format!("Tagged {source} as {target}")))
}
fn container_inspect(
program: &str,
builtin: &str,
args: &[Value],
input: &Option<Value>,
) -> Result<Value> {
refuse_pipeline(builtin, input)?;
let name = str_operand(builtin, "container or image name", args.first())?;
container_operand(builtin, "container or image name", name)?;
let text = container_cli(builtin, program, &["inspect", "--", name], false)?;
let json: serde_json::Value = serde_json::from_str(text.trim()).map_err(|_| {
container_error(
builtin,
format!("the output of `{program} inspect` is not JSON"),
"the tool ran but printed output this builtin does not parse; check its version",
"unreadable output",
)
})?;
Ok(json_to_value(json))
}
fn container_top(
program: &str,
builtin: &str,
args: &[Value],
input: &Option<Value>,
) -> Result<Value> {
refuse_pipeline(builtin, input)?;
let container = str_operand(builtin, "container", args.first())?;
container_operand(builtin, "container", container)?;
let text = container_cli(builtin, program, &["top", "--", container], false)?;
// The first line is the header; each following line is one process.
let mut lines = text.lines();
let Some(header) = lines.next() else {
return Ok(Value::Array(vec![]));
};
let headers: Vec<String> = header
.split_whitespace()
.map(|h| h.to_lowercase())
.collect();
let mut processes = Vec::new();
for line in lines {
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.is_empty() {
continue;
}
let mut record = BTreeMap::new();
for (i, header) in headers.iter().enumerate() {
let val = fields.get(i).copied().unwrap_or("");
record.insert(header.clone(), Value::Str(val.to_string()));
}
processes.push(Value::Record(record));
}
Ok(Value::Array(processes))
}
/// `docker cp` between the host and a container. Exactly one operand is
/// `container:path`; the other is a host path, guarded as a write when the
/// container's files are copied out to it and as a read when they are copied in.
fn container_cp(
program: &str,
builtin: &str,
args: &[Value],
input: &Option<Value>,
) -> Result<Value> {
refuse_pipeline(builtin, input)?;
let src = str_operand(builtin, "source path", args.first())?;
let dest = str_operand(builtin, "destination path", args.get(1))?;
let src_in_container = cp_is_container_operand(src);
if src_in_container == cp_is_container_operand(dest) {
return Err(crate::safety::arg_err(format!(
"{builtin}: exactly one of source and destination must be container:path; \
the other is a host path"
)));
}
let (container_side, host_side) = if src_in_container {
(src, dest)
} else {
(dest, src)
};
let in_container = container_side.split_once(':').map_or("", |(_, p)| p);
if in_container.is_empty() || in_container.chars().any(char::is_control) {
return Err(crate::safety::bad_arg(
builtin,
"container:path with a non-empty path",
container_side,
));
}
let (src_arg, dest_arg) = if src_in_container {
(src.to_string(), guard_local_write(builtin, host_side)?)
} else {
(guard_local_read(builtin, host_side)?, dest.to_string())
};
container_cli(
builtin,
program,
&["cp", "--", src_arg.as_str(), dest_arg.as_str()],
false,
)?;
Ok(Value::Str(format!("Copied {src} -> {dest}")))
}
/// Whether a `docker cp` operand names a container. Like the CLI, the text
/// before the first colon is the name; a name cannot contain a separator and is
/// at least two characters, so `C:\x` and `./a:b` are host paths.
fn cp_is_container_operand(operand: &str) -> bool {
let Some((name, _)) = operand.split_once(':') else {
return false;
};
name.len() >= 2
&& name.starts_with(|c: char| c.is_ascii_alphanumeric())
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
}
fn compose_up(builtin: &str, args: &[Value], input: &Option<Value>) -> Result<Value> {
refuse_pipeline(builtin, input)?;
crate::safety::check_egress_unknown(
builtin,
"the images a compose file names are pulled, and the file is read from the project directory, not the arguments",
)?;
// Detached by default. A foreground `compose up` runs until it is stopped,
// which no call can bound, so only `true` is accepted.
let detach = match args.first() {
None => true,
Some(Value::Bool(b)) => *b,
Some(other) => {
return Err(crate::safety::bad_arg(
builtin,
"detach: Bool",
other.type_name(),
))
}
};
if !detach {
return Err(crate::safety::arg_err(format!(
"{builtin}: detach must be true; a foreground compose up runs until it is \
stopped, which no call can bound"
)));
}
let file = compose_file_arg(builtin, args.get(1))?;
let mut cmd = vec!["compose"];
if let Some(file) = file.as_deref() {
cmd.extend(["-f", file]);
}
cmd.extend(["up", "-d"]);
let text = container_cli(builtin, "docker", &cmd, true)?;
Ok(Value::Str(text))
}
fn compose_down_run(file: Option<&str>) -> Result<Value> {
let mut cmd = vec!["compose"];
if let Some(file) = file {
cmd.extend(["-f", file]);
}
cmd.push("down");
let text = container_cli("docker_compose_down", "docker", &cmd, true)?;
Ok(Value::Str(text))
}
fn compose_ps(builtin: &str, args: &[Value], input: &Option<Value>) -> Result<Value> {
refuse_pipeline(builtin, input)?;
let file = compose_file_arg(builtin, args.first())?;
let mut cmd = vec!["compose"];
if let Some(file) = file.as_deref() {
cmd.extend(["-f", file]);
}
cmd.extend(["ps", "--format", "json"]);
let text = container_cli(builtin, "docker", &cmd, false)?;
Ok(Value::Array(container_rows(builtin, "docker", &text)?))
}
// ---------------------------------------------------------------------------
// 816: bi_docker_ps – List containers
// ---------------------------------------------------------------------------
fn bi_docker_ps(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let all = args
.first()
.map(|v| matches!(v, Value::Bool(true)))
.unwrap_or(false);
let mut cmd_args = vec!["ps", "--format", "{{json .}}"];
if all {
cmd_args.push("-a");
}
let containers = container_json_lines("docker", &cmd_args)?;
Ok(Value::Array(containers))
}
// ---------------------------------------------------------------------------
// 817: bi_docker_images – List images
// ---------------------------------------------------------------------------
fn bi_docker_images(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let _ = args;
let images = container_json_lines("docker", &["images", "--format", "{{json .}}"])?;
Ok(Value::Array(images))
}
// ---------------------------------------------------------------------------
// 818: bi_docker_run – Run a container
// ---------------------------------------------------------------------------
fn bi_docker_run(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
container_run("docker", "docker_run", &args, &input)
}
// ---------------------------------------------------------------------------
// 819: bi_docker_exec – Execute an argv in a container
// ---------------------------------------------------------------------------
fn bi_docker_exec(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let (container, argv) = exec_request("docker_exec", &args, &input)?;
crate::safety::guard_exec(
"docker_exec",
format!("docker exec {container} {}", argv.join(" ")),
)?;
exec_run("docker", "docker_exec", &container, &argv)
}
// ---------------------------------------------------------------------------
// 820: bi_docker_logs – Get container logs (bounded tail, stdout then stderr)
// ---------------------------------------------------------------------------
fn bi_docker_logs(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
container_logs("docker", "docker_logs", &args, &input)
}
// ---------------------------------------------------------------------------
// 821: bi_docker_stop – Stop a container
// ---------------------------------------------------------------------------
fn bi_docker_stop(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
container_stop("docker", "docker_stop", &args, &input)
}
// ---------------------------------------------------------------------------
// 822: bi_docker_rm – Remove a container
// ---------------------------------------------------------------------------
fn bi_docker_rm(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let (container, force) = rm_request("docker_rm", &args, &input)?;
crate::safety::guard(crate::safety::GuardCtx {
builtin: "docker_rm",
effect: crate::safety::Effect::Destructive,
what: "container_remove",
targets: vec![container.clone()],
blast_radius: serde_json::json!({ "container": container, "force": force }),
reversible: false,
fs_paths: false,
})?;
rm_run("docker", "docker_rm", &container, force)
}
// ---------------------------------------------------------------------------
// 823: bi_docker_build – Build an image from a local context
// ---------------------------------------------------------------------------
fn bi_docker_build(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
container_build("docker", "docker_build", &args, &input)
}
// ---------------------------------------------------------------------------
// 824: bi_docker_pull – Pull an image
// ---------------------------------------------------------------------------
fn bi_docker_pull(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
container_image_op("docker", "docker_pull", "pull", &args, &input)
}
// ---------------------------------------------------------------------------
// 825: bi_docker_push – Push an image
// ---------------------------------------------------------------------------
fn bi_docker_push(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
container_image_op("docker", "docker_push", "push", &args, &input)
}
// ---------------------------------------------------------------------------
// 826: bi_docker_inspect – Inspect a container or image (JSON array)
// ---------------------------------------------------------------------------
fn bi_docker_inspect(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
container_inspect("docker", "docker_inspect", &args, &input)
}
// ---------------------------------------------------------------------------
// 827: bi_docker_volumes – List volumes
// ---------------------------------------------------------------------------
fn bi_docker_volumes(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let _ = args;
let volumes = container_json_lines("docker", &["volume", "ls", "--format", "{{json .}}"])?;
Ok(Value::Array(volumes))
}
// ---------------------------------------------------------------------------
// 828: bi_docker_networks – List networks
// ---------------------------------------------------------------------------
fn bi_docker_networks(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let _ = args;
let networks = container_json_lines("docker", &["network", "ls", "--format", "{{json .}}"])?;
Ok(Value::Array(networks))
}
// ---------------------------------------------------------------------------
// 829: bi_docker_compose_up – Start a compose project detached
// ---------------------------------------------------------------------------
fn bi_docker_compose_up(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
compose_up("docker_compose_up", &args, &input)
}
// ---------------------------------------------------------------------------
// 830: bi_docker_compose_down – Stop and remove a compose project
// ---------------------------------------------------------------------------
fn bi_docker_compose_down(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_pipeline("docker_compose_down", &input)?;
let file = compose_file_arg("docker_compose_down", args.first())?;
crate::safety::guard(crate::safety::GuardCtx {
builtin: "docker_compose_down",
effect: crate::safety::Effect::Destructive,
what: "compose_down",
targets: vec![file
.clone()
.unwrap_or_else(|| "<default compose>".to_string())],
blast_radius: serde_json::json!({}),
reversible: false,
fs_paths: false,
})?;
compose_down_run(file.as_deref())
}
// ---------------------------------------------------------------------------
// 831: bi_docker_compose_ps – List a compose project's containers
// ---------------------------------------------------------------------------
fn bi_docker_compose_ps(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
compose_ps("docker_compose_ps", &args, &input)
}
// ---------------------------------------------------------------------------
// 832: bi_docker_stats – Container stats (no-stream)
// ---------------------------------------------------------------------------
fn bi_docker_stats(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let _ = args;
let stats = container_json_lines(
"docker",
&["stats", "--no-stream", "--format", "{{json .}}"],
)?;
Ok(Value::Array(stats))
}
// ---------------------------------------------------------------------------
// 833: bi_docker_top – Container processes, one record per process
// ---------------------------------------------------------------------------
fn bi_docker_top(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
container_top("docker", "docker_top", &args, &input)
}
// ---------------------------------------------------------------------------
// 834: bi_docker_cp – Copy files between the host and a container
// ---------------------------------------------------------------------------
fn bi_docker_cp(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
container_cp("docker", "docker_cp", &args, &input)
}
// ---------------------------------------------------------------------------
// 835: bi_docker_tag – Tag an image
// ---------------------------------------------------------------------------
fn bi_docker_tag(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
container_tag("docker", "docker_tag", &args, &input)
}
// ===========================================================================
// Podman Builtins (836-844) – the same operations through the "podman" CLI
// ===========================================================================
// ---------------------------------------------------------------------------
// 836: bi_podman_ps – List containers (podman)
// ---------------------------------------------------------------------------
fn bi_podman_ps(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "podman_ps";
refuse_pipeline(B, &input)?;
let all = optional_bool(B, "all", args.first())?;
let mut cmd = vec!["ps", "--format", "json"];
if all {
cmd.push("-a");
}
let text = container_cli(B, "podman", &cmd, false)?;
Ok(Value::Array(container_rows(B, "podman", &text)?))
}
// ---------------------------------------------------------------------------
// 837: bi_podman_images – List images (podman)
// ---------------------------------------------------------------------------
fn bi_podman_images(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "podman_images";
refuse_pipeline(B, &input)?;
let _ = args;
let text = container_cli(B, "podman", &["images", "--format", "json"], false)?;
Ok(Value::Array(container_rows(B, "podman", &text)?))
}
// ---------------------------------------------------------------------------
// 838: bi_podman_run – Run a container (podman)
// ---------------------------------------------------------------------------
fn bi_podman_run(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
container_run("podman", "podman_run", &args, &input)
}
// ---------------------------------------------------------------------------
// 839: bi_podman_exec – Execute an argv in a container (podman)
// ---------------------------------------------------------------------------
fn bi_podman_exec(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let (container, argv) = exec_request("podman_exec", &args, &input)?;
crate::safety::guard_exec(
"podman_exec",
format!("podman exec {container} {}", argv.join(" ")),
)?;
exec_run("podman", "podman_exec", &container, &argv)
}
// ---------------------------------------------------------------------------
// 840: bi_podman_logs – Get container logs (podman)
// ---------------------------------------------------------------------------
fn bi_podman_logs(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
container_logs("podman", "podman_logs", &args, &input)
}
// ---------------------------------------------------------------------------
// 841: bi_podman_stop – Stop a container (podman)
// ---------------------------------------------------------------------------
fn bi_podman_stop(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
container_stop("podman", "podman_stop", &args, &input)
}
// ---------------------------------------------------------------------------
// 842: bi_podman_rm – Remove a container (podman)
// ---------------------------------------------------------------------------
fn bi_podman_rm(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
// podman_rm is Destructive and listed in SELF_GUARDED, so the dispatcher
// leaves the policy check to this body, as it does for bi_docker_rm.
let (container, force) = rm_request("podman_rm", &args, &input)?;
crate::safety::guard(crate::safety::GuardCtx {
builtin: "podman_rm",
effect: crate::safety::Effect::Destructive,
what: "container_remove",
targets: vec![container.clone()],
blast_radius: serde_json::json!({ "container": container, "force": force }),
reversible: false,
fs_paths: false,
})?;
rm_run("podman", "podman_rm", &container, force)
}
// ---------------------------------------------------------------------------
// 843: bi_podman_build – Build an image from a local context (podman)
// ---------------------------------------------------------------------------
fn bi_podman_build(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
container_build("podman", "podman_build", &args, &input)
}
// ---------------------------------------------------------------------------
// 844: bi_podman_pull – Pull an image (podman)
// ---------------------------------------------------------------------------
fn bi_podman_pull(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
container_image_op("podman", "podman_pull", "pull", &args, &input)
}
// ============================================================================
// Kubernetes & Helm Builtins (850-879)
// kubectl, helm operations with structured output
// ============================================================================
// ---------------------------------------------------------------------------
// kubectl runner shared by every k8s_* builtin.
//
// argv only, never a shell. A hard deadline ends a call that hangs (a rollout
// that never finishes, an API server that does not answer). Output is bounded:
// stdout is held up to `max_bytes` and refused beyond that, and stderr keeps
// only its tail. A nonzero exit is a coded E_TOOL_FAILED carrying that tail; a
// kubectl that is not installed is E_TOOL_MISSING.
// ---------------------------------------------------------------------------
/// How long one kubectl call may run before it is killed.
const K8S_DEADLINE_SECS: u64 = 30;
/// Largest text output: describe, logs, apply, exec, cluster-info, top.
const K8S_MAX_TEXT: usize = 4 * 1024 * 1024;
/// Largest JSON output: get and the list builtins.
const K8S_MAX_JSON: usize = 16 * 1024 * 1024;
/// The last bytes of stderr kept for a failure message.
const K8S_STDERR_TAIL: usize = 2048;
/// Log lines k8s_logs returns when no tail is given, and the most it will return.
const K8S_LOG_DEFAULT_TAIL: i64 = 200;
const K8S_LOG_MAX_TAIL: i64 = 10_000;
/// Largest replica count k8s_scale will request.
const K8S_MAX_REPLICAS: i64 = 1_000;
/// Most argv elements k8s_exec will pass to a container.
const K8S_MAX_EXEC_ARGV: usize = 256;
/// A check that turns one caller string into a value kubectl may be given.
type K8sCheck = fn(&str, &str, &str) -> Result<String>;
/// Run `kubectl` with `args` and return its stdout.
fn kubectl_run(builtin: &str, args: &[&str], max_bytes: usize) -> Result<String> {
kubectl_run_program(builtin, "kubectl", args, max_bytes, K8S_DEADLINE_SECS)
}
/// [`kubectl_run`] with the program and the deadline given, so a test can aim
/// it at a stand-in. Through the shared bounded runner: past `deadline_secs`
/// the whole process tree is killed and the call is a coded budget error, and
/// a grandchild that keeps the pipes open cannot hold the call.
fn kubectl_run_program(
builtin: &str,
program: &str,
args: &[&str],
max_bytes: usize,
deadline_secs: u64,
) -> Result<String> {
let command = format!("{program} {}", args.join(" "));
let run = run_tree(
program,
args,
|_| {},
None,
std::time::Duration::from_secs(deadline_secs),
Keep::Head(max_bytes),
Keep::Tail(K8S_STDERR_TAIL),
)
.map_err(|e| match e {
TreeError::Spawn(e) => crate::safety::spawn_error(builtin, program, &e),
TreeError::Wait(e) => crate::safety::tool_failed(builtin, &command, None, &e.to_string()),
})?;
if run.timed_out {
return Err(crate::safety::budget_exceeded(
builtin,
format!("`{command}` did not finish within {deadline_secs} s"),
"check that the cluster answers, or narrow the call",
));
}
if !run.success {
return Err(crate::safety::tool_failed(
builtin,
&command,
run.code,
&String::from_utf8_lossy(&run.stderr),
));
}
if run.stdout_truncated {
return Err(crate::safety::budget_exceeded(
builtin,
format!("`{command}` printed more than {max_bytes} bytes"),
"narrow the call: give a namespace or a name, or ask for fewer lines",
));
}
Ok(String::from_utf8_lossy(&run.stdout).into_owned())
}
/// Test hook: [`kubectl_run`] against `program` with a chosen deadline. Not
/// part of the public API.
#[doc(hidden)]
pub fn __test_kubectl_run(
program: &str,
args: &[&str],
max_bytes: usize,
deadline_secs: u64,
) -> Result<String> {
kubectl_run_program("test", program, args, max_bytes, deadline_secs)
}
/// Run `kubectl` with JSON output and parse it.
fn kubectl_json(builtin: &str, args: &[&str], max_bytes: usize) -> Result<Value> {
let text = kubectl_run(builtin, args, max_bytes)?;
match serde_json::from_str::<serde_json::Value>(&text) {
Ok(json) => Ok(json_to_value(json)),
Err(e) => Err(crate::safety::tool_failed(
builtin,
&format!("kubectl {}", args.join(" ")),
Some(0),
&format!("printed output that is not JSON ({e})"),
)),
}
}
/// A resource type, object name, context or namespace as kubectl will read it:
/// 1-253 letters, digits or `-._/:@,`, and never a leading `-`, which kubectl
/// would take for a flag. The leading-`-` refusal is the option-injection
/// guard; the character set keeps everything else inside what names can be.
fn k8s_token(builtin: &str, what: &str, value: &str) -> Result<String> {
crate::safety::reject_option_like(builtin, &[value.to_string()])?;
let well_formed = !value.is_empty()
&& value.len() <= 253
&& value
.chars()
.all(|c| c.is_ascii_alphanumeric() || "-._/:@,".contains(c));
if !well_formed {
return Err(crate::safety::arg_err(format!(
"{builtin}: {what} must be 1-253 characters of letters, digits or -._/:@, (got {value:?})"
)));
}
Ok(value.to_string())
}
/// A Kubernetes object name or namespace: the token rule, narrowed to letters,
/// digits, `-` and `.`, so no resource path or list separator can be smuggled
/// into a `type/name` argument.
fn k8s_object_name(builtin: &str, what: &str, value: &str) -> Result<String> {
let name = k8s_token(builtin, what, value)?;
if name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.')
{
Ok(name)
} else {
Err(crate::safety::arg_err(format!(
"{builtin}: {what} is a Kubernetes object name (letters, digits, '-' and '.'), got {value:?}"
)))
}
}
/// The string argument at `index`, checked, when it is present.
fn k8s_arg(
builtin: &str,
what: &str,
args: &[Value],
index: usize,
check: K8sCheck,
) -> Result<Option<String>> {
match args.get(index) {
None => Ok(None),
Some(Value::Str(s)) => check(builtin, what, s).map(Some),
Some(other) => Err(crate::safety::arg_err(format!(
"{builtin}: {what} must be a String, got {}",
other.type_name()
))),
}
}
/// The string argument at `index`, checked, and required.
fn k8s_need(
builtin: &str,
what: &str,
args: &[Value],
index: usize,
check: K8sCheck,
) -> Result<String> {
k8s_arg(builtin, what, args, index, check)?
.ok_or_else(|| crate::safety::arg_err(format!("{builtin} requires {what}")))
}
/// Refuse a pipeline subject. None of the k8s_* builtins takes one, and an
/// ignored subject would look like a filtered result.
fn k8s_no_input(builtin: &str, input: &Option<Value>) -> Result<()> {
if input.is_some() {
return Err(crate::safety::arg_err(format!(
"{builtin} takes no pipeline input"
)));
}
Ok(())
}
/// Refuse more arguments than the builtin takes; none is silently dropped.
fn k8s_max_args(builtin: &str, args: &[Value], max: usize) -> Result<()> {
if args.len() > max {
return Err(crate::safety::arg_err(format!(
"{builtin} takes at most {max} argument(s), got {}",
args.len()
)));
}
Ok(())
}
/// Append `-n <namespace>` when a namespace was given.
fn k8s_push_ns<'a>(cmd: &mut Vec<&'a str>, ns: Option<&'a str>) {
if let Some(ns) = ns {
cmd.extend(["-n", ns]);
}
}
/// `kubectl get <resource> -o json`, for the list builtins. A namespaced list
/// takes an optional namespace; a cluster-scoped one takes no arguments.
fn k8s_list(
builtin: &str,
resource: &str,
namespaced: bool,
args: &[Value],
input: Option<Value>,
) -> Result<Value> {
k8s_no_input(builtin, &input)?;
k8s_max_args(builtin, args, usize::from(namespaced))?;
let ns = if namespaced {
k8s_arg(builtin, "namespace", args, 0, k8s_object_name)?
} else {
None
};
let mut cmd: Vec<&str> = vec!["get", "-o", "json"];
k8s_push_ns(&mut cmd, ns.as_deref());
cmd.extend(["--", resource]);
kubectl_json(builtin, &cmd, K8S_MAX_JSON)
}
// ---------------------------------------------------------------------------
// Helm and Terraform: one bounded runner, and the argument checks both share
// ---------------------------------------------------------------------------
/// Deadline for the quick calls: reads of local or cluster state.
const TOOL_DEADLINE_SECS: u64 = 120;
/// Deadline for calls that can run for minutes: provider downloads, plans that
/// refresh, applies, destroys, and helm installs and upgrades.
const TOOL_LONG_DEADLINE_SECS: u64 = 900;
/// The most output a call that prints JSON may produce.
const TOOL_MAX_JSON: usize = 16 * 1024 * 1024;
/// The most output a read-only text call may produce.
const TOOL_MAX_TEXT: usize = 4 * 1024 * 1024;
/// Output kept from a call that changes state. Its work is finished by the time
/// its report is read, so an oversized report keeps its end and says so.
const TOOL_KEEP_TAIL: usize = 256 * 1024;
/// Stderr kept for a failure message.
const TOOL_STDERR_TAIL: usize = 2048;
/// The most `--set` overrides one helm call passes.
const HELM_MAX_SET: usize = 256;
/// The longest Terraform workspace name.
const TF_MAX_WORKSPACE: usize = 90;
/// How much of a call's output is kept.
#[derive(Clone, Copy)]
pub(crate) enum Keep {
/// The first `n` bytes. More is an error: a read cut short would look whole.
Head(usize),
/// The last `n` bytes, with a note when earlier output was dropped.
Tail(usize),
}
/// One finished call: how it ended and what it printed.
pub(crate) struct ToolRun {
pub(crate) command: String,
pub(crate) keep: Keep,
pub(crate) success: bool,
pub(crate) code: Option<i32>,
pub(crate) stdout: String,
pub(crate) stdout_truncated: bool,
pub(crate) stderr: String,
/// Whether earlier stderr was dropped to fit `stderr_keep` (the tail is kept).
pub(crate) stderr_truncated: bool,
}
/// Read a pipe to its end, keeping its last `keep` bytes, and report whether
/// more than that was printed.
fn tool_read_tail(mut pipe: impl std::io::Read, keep: usize) -> (Vec<u8>, bool) {
let mut buf: Vec<u8> = Vec::new();
let mut total: usize = 0;
let mut chunk = [0u8; 8192];
loop {
match pipe.read(&mut chunk) {
Ok(0) | Err(_) => break,
Ok(n) => {
total = total.saturating_add(n);
buf.extend_from_slice(&chunk[..n]);
if buf.len() > 2 * keep {
let cut = buf.len() - keep;
buf.drain(..cut);
}
}
}
}
let truncated = total > keep;
if buf.len() > keep {
let cut = buf.len() - keep;
buf.drain(..cut);
}
(buf, truncated)
}
/// Run `program` with `args` in `dir` (the process's directory when `None`),
/// directly and never through a shell, with stdin closed. Past `deadline_secs`
/// the child is killed and the call is refused. Nothing is judged here: the
/// caller decides what a finished call means.
fn tool_run(
builtin: &str,
program: &str,
dir: Option<&std::path::Path>,
args: &[&str],
keep: Keep,
deadline_secs: u64,
) -> Result<ToolRun> {
tool_run_stderr(
builtin,
program,
dir,
args,
keep,
TOOL_STDERR_TAIL,
deadline_secs,
)
}
/// [`tool_run`] that keeps `stderr_keep` bytes of stderr instead of the usual
/// failure-message tail. A tool whose report is written to stderr (valgrind)
/// needs more than the tail; its failure message is cut again by the caller.
fn tool_run_stderr(
builtin: &str,
program: &str,
dir: Option<&std::path::Path>,
args: &[&str],
keep: Keep,
stderr_keep: usize,
deadline_secs: u64,
) -> Result<ToolRun> {
let mut run = run_bounded(
builtin,
program,
dir,
args,
keep,
stderr_keep,
deadline_secs,
)?;
run.stderr = strip_ansi(&run.stderr).trim().to_string();
Ok(run)
}
/// The bounded runner behind [`tool_run_stderr`], with stderr left as the
/// program wrote it. Stdout keeps `keep` and stderr its last `stderr_keep`
/// bytes. Past `deadline_secs` the whole process tree is killed and the call is
/// refused. Never through a shell, with stdin closed.
pub(crate) fn run_bounded(
builtin: &str,
program: &str,
dir: Option<&std::path::Path>,
args: &[&str],
keep: Keep,
stderr_keep: usize,
deadline_secs: u64,
) -> Result<ToolRun> {
let command = format!("{program} {}", args.join(" "));
let run = run_tree(
program,
args,
|cmd| {
if let Some(dir) = dir {
cmd.current_dir(dir);
}
if program == "terraform" {
// Automation mode drops the interactive "run terraform apply" hints.
cmd.env("TF_IN_AUTOMATION", "1");
}
},
None,
std::time::Duration::from_secs(deadline_secs),
keep,
Keep::Tail(stderr_keep),
)
.map_err(|e| match e {
TreeError::Spawn(e) => crate::safety::spawn_error(builtin, program, &e),
TreeError::Wait(e) => crate::safety::tool_failed(builtin, &command, None, &e.to_string()),
})?;
if run.timed_out {
return Err(crate::safety::budget_exceeded(
builtin,
format!("`{command}` did not finish within {deadline_secs} s"),
"a killed call can leave work half done; check the state with the matching read builtin before retrying",
));
}
Ok(ToolRun {
command,
keep,
success: run.success,
code: run.code,
stdout: String::from_utf8_lossy(&run.stdout).into_owned(),
stdout_truncated: run.stdout_truncated,
stderr: String::from_utf8_lossy(&run.stderr).into_owned(),
stderr_truncated: run.stderr_truncated,
})
}
/// How one bounded run ended. A pipe that a descendant of the child kept open
/// past the grace is abandoned: what it had produced is kept, and
/// `pipes_abandoned` says so. The `_truncated` flags mean only that output went
/// past its bound, so a child that finished while a helper it started still
/// held the pipe is not reported as having printed too much.
pub(crate) struct TreeRun {
/// The child exited on its own with status 0.
pub(crate) success: bool,
/// The exit code; `None` when killed by a signal or at the deadline.
pub(crate) code: Option<i32>,
/// The deadline passed and the child's tree was killed.
pub(crate) timed_out: bool,
pub(crate) stdout: Vec<u8>,
pub(crate) stdout_truncated: bool,
pub(crate) stderr: Vec<u8>,
pub(crate) stderr_truncated: bool,
/// A descendant still held stdout or stderr after the grace, so whatever
/// it would have printed after that was not read.
pub(crate) pipes_abandoned: bool,
/// The child's exit status; `None` only when the deadline killed it.
pub(crate) status: Option<std::process::ExitStatus>,
}
/// Why a bounded run could not be completed.
pub(crate) enum TreeError {
/// The program did not start.
Spawn(std::io::Error),
/// Waiting on the child failed; the child's tree was killed.
Wait(std::io::Error),
}
/// How long a run's pipes may stay open after its child has exited or been
/// killed. Only a descendant that outlived the child can keep them open.
const PIPE_GRACE: std::time::Duration = std::time::Duration::from_secs(2);
/// How long the pipes may take to close once a leftover group was killed.
const AFTER_KILL_GRACE: std::time::Duration = std::time::Duration::from_millis(500);
/// Bound on one `taskkill` at a deadline, so the kill itself cannot hang a call.
#[cfg(windows)]
const TASKKILL_LIMIT: std::time::Duration = std::time::Duration::from_secs(10);
/// The one bounded runner that the builtins, the diagnostics runner and the
/// tool-run runner share. `program` runs with `args` directly (never through a
/// shell); `configure` may set the directory and environment; `stdin` is a
/// fixed payload, or the child gets a null stdin when `None`.
///
/// The child leads its own process group (unix), and on Windows it is put in a
/// job object at once (see [`TreeJob`]). At `limit` the child's tree is killed
/// (the group on unix; the job, then `taskkill /T`, on Windows) and the child is
/// reaped. After a normal exit, a pipe still open after [`PIPE_GRACE`] means a
/// leftover descendant holds it, so the group is killed (the job on Windows). A
/// pipe still open after the grace that follows a kill is abandoned, not joined.
/// The call therefore takes at most `limit` plus a few seconds, whatever the
/// descendants do.
///
/// On Windows the job reaches descendants whose parent has already exited, which
/// `taskkill /T` cannot walk. A descendant started before the child joined the
/// job escapes every kill here; that window is short but not zero.
pub(crate) fn run_tree(
program: &str,
args: &[&str],
configure: impl FnOnce(&mut std::process::Command),
stdin: Option<Vec<u8>>,
limit: std::time::Duration,
stdout_keep: Keep,
stderr_keep: Keep,
) -> std::result::Result<TreeRun, TreeError> {
let mut command = crate::safety::child_command(program);
command.args(args);
configure(&mut command);
run_tree_cmd(command, stdin, limit, stdout_keep, stderr_keep)
}
/// [`run_tree`] for a `Command` the caller has already built (its program,
/// arguments, environment and directory are kept). Its stdio is set here, so
/// the caller's stdin, stdout and stderr settings are replaced.
pub(crate) fn run_tree_cmd(
command: std::process::Command,
stdin: Option<Vec<u8>>,
limit: std::time::Duration,
stdout_keep: Keep,
stderr_keep: Keep,
) -> std::result::Result<TreeRun, TreeError> {
run_tree_cmd_observed(command, stdin, limit, stdout_keep, stderr_keep, |_, _| {})
}
/// [`run_tree_cmd`] that also hands `observe` the child once the runner is done
/// with it (it exited, or was killed at the limit and reaped), before the
/// child's handle is closed. The second argument is the time from the start of
/// the wait to that point. Only a caller that reads the child's handle needs
/// this: perf stat reads process times and memory from it on Windows.
pub(crate) fn run_tree_cmd_observed(
mut command: std::process::Command,
stdin: Option<Vec<u8>>,
limit: std::time::Duration,
stdout_keep: Keep,
stderr_keep: Keep,
observe: impl FnOnce(&std::process::Child, std::time::Duration),
) -> std::result::Result<TreeRun, TreeError> {
use std::process::Stdio;
crate::safety::strip_ironshell_env(&mut command);
command
.stdin(if stdin.is_some() {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.process_group(0);
}
let mut child = command.spawn().map_err(TreeError::Spawn)?;
#[cfg(windows)]
let job = TreeJob::for_child(&child);
if let (Some(payload), Some(mut pipe)) = (stdin, child.stdin.take()) {
// Detached: a child that never reads its stdin blocks this write until
// the kill closes the pipe, which then fails the write.
std::thread::spawn(move || {
use std::io::Write;
let _ = pipe.write_all(&payload);
});
}
let out = PipeReader::start(child.stdout.take(), stdout_keep);
let err = PipeReader::start(child.stderr.take(), stderr_keep);
let started = std::time::Instant::now();
let exited = loop {
match child.try_wait() {
Ok(Some(status)) => break Some(status),
Ok(None) => {}
Err(e) => {
#[cfg(windows)]
kill_job(job.as_ref());
kill_tree(&mut child);
return Err(TreeError::Wait(e));
}
}
if started.elapsed() >= limit {
break None;
}
std::thread::sleep(std::time::Duration::from_millis(5));
};
let waited = started.elapsed();
let (success, code, status, timed_out, out_closed, err_closed) = match exited {
Some(status) => {
let (mut out_closed, mut err_closed) = wait_pipes(&out, &err, PIPE_GRACE);
if !(out_closed && err_closed) {
// A descendant that outlived the child holds a pipe: kill its
// group rather than wait for it.
#[cfg(windows)]
kill_job(job.as_ref());
kill_group(child.id());
(out_closed, err_closed) = wait_pipes(&out, &err, AFTER_KILL_GRACE);
}
(
status.success(),
status.code(),
Some(status),
false,
out_closed,
err_closed,
)
}
None => {
#[cfg(windows)]
kill_job(job.as_ref());
kill_tree(&mut child);
let (out_closed, err_closed) = wait_pipes(&out, &err, PIPE_GRACE);
(false, None, None, true, out_closed, err_closed)
}
};
observe(&child, waited);
let (stdout, stdout_truncated) = out.finish();
let (stderr, stderr_truncated) = err.finish();
Ok(TreeRun {
success,
code,
timed_out,
stdout,
stdout_truncated,
stderr,
stderr_truncated,
pipes_abandoned: !out_closed || !err_closed,
status,
})
}
/// Deadline for a probe that only asks a tool about itself or the host
/// (`--version`, `uname`, `which`, a status read). It bounds a hung probe, not
/// a slow one: 10 s was measured too short, because a cold `powershell` start
/// under a parallel test run took longer than that on Windows.
pub(crate) const PROBE_DEADLINE_SECS: u64 = 30;
/// `cmd.output()` with a deadline: the whole process tree is killed past
/// `deadline_secs`, and output over 64 MiB is refused (see
/// [`crate::safety::output_with_timeout`]). A tool that is absent is
/// E_TOOL_MISSING, a deadline is E_BUDGET_EXCEEDED, and a nonzero exit is left
/// in the returned status for the caller to judge, as `output()` left it.
pub(crate) fn bounded_cmd_output(
builtin: &str,
tool: &str,
cmd: std::process::Command,
deadline_secs: u64,
) -> Result<std::process::Output> {
crate::safety::output_with_timeout(cmd, deadline_secs)
.map_err(|e| crate::safety::spawn_error(builtin, tool, &e))
}
/// Run `tool` (executed as `program`) through [`run_tree`] and return the
/// `Output` its callers read. Stdin is written on run_tree's own thread, so a
/// payload larger than the pipe buffer cannot deadlock against a child that is
/// still writing. Past `deadline_secs` the child's tree is killed and the call
/// is a coded budget error. Output over `max_output` in either stream is also a
/// budget error: a report cut short would look whole. Nothing is judged here.
pub(crate) fn run_output(
builtin: &str,
tool: &str,
program: &str,
args: &[&str],
configure: impl FnOnce(&mut std::process::Command),
stdin: Option<Vec<u8>>,
deadline_secs: u64,
max_output: usize,
) -> Result<std::process::Output> {
let run = run_tree(
program,
args,
configure,
stdin,
std::time::Duration::from_secs(deadline_secs),
Keep::Head(max_output),
Keep::Head(max_output),
)
.map_err(|e| match e {
TreeError::Spawn(e) | TreeError::Wait(e) => crate::safety::spawn_error(builtin, tool, &e),
})?;
match run.status {
Some(status) if !run.timed_out => {
if run.stdout_truncated || run.stderr_truncated {
return Err(crate::safety::budget_exceeded(
builtin,
format!("`{tool}` printed more than {max_output} bytes"),
"the output was not used; the call was refused rather than cut short",
));
}
Ok(std::process::Output {
status,
stdout: run.stdout,
stderr: run.stderr,
})
}
_ => Err(crate::safety::budget_exceeded(
builtin,
format!("`{tool}` did not finish within {deadline_secs} s"),
"the call was killed and its result discarded; check the state with the matching read builtin before retrying",
)),
}
}
/// Test hook: the scheduler's tool runner, so a test can feed it a payload
/// larger than the pipe buffer. Not part of the public API.
#[doc(hidden)]
pub fn __test_scheduler_run_tool(
tool: &str,
args: &[&str],
stdin: Option<&[u8]>,
) -> Result<std::process::Output> {
scheduler::run_tool("test", tool, args, stdin)
}
/// Test hook: the container CLI runner with its deadline given in seconds.
/// Not part of the public API.
#[doc(hidden)]
pub fn __test_container_cli(program: &str, args: &[&str], deadline_secs: u64) -> Result<String> {
container_cli_within("test", program, args, false, deadline_secs)
}
/// Test hook: the checked tool runner (`run_checked`, behind `run_tool`) with
/// its deadline given in seconds. Not part of the public API.
#[doc(hidden)]
pub fn __test_run_checked(tool: &str, args: &[&str], deadline_secs: u64) -> Result<String> {
let mut cmd = crate::safety::child_command(tool);
cmd.args(args);
run_checked("test", tool, cmd, deadline_secs).map(|(out, _)| out)
}
/// Test hook: the package manager's exec helper (Linux only) with its deadline
/// given in seconds. Not part of the public API.
#[cfg(target_os = "linux")]
#[doc(hidden)]
pub fn __test_pkg_exec(
tool: &str,
args: &[&str],
deadline_secs: u64,
) -> Result<std::process::Output> {
let argv: Vec<String> = args.iter().map(|arg| arg.to_string()).collect();
pkg_admin::exec_for("test", tool, &argv, deadline_secs)
}
/// Test hook: the account tools' spawn helper (Linux; elsewhere a refusal).
/// Not part of the public API.
#[doc(hidden)]
pub fn __test_accounts_spawn(
tool: &str,
args: &[&str],
stdin: Option<&str>,
) -> Result<std::process::Output> {
accounts::spawn("test", tool, args, stdin)
}
/// What one pipe has produced, kept to its `Keep` bound while it is read.
struct PipeBuf {
keep: Keep,
kept: Vec<u8>,
total: usize,
}
impl PipeBuf {
fn limit(&self) -> usize {
match self.keep {
Keep::Head(n) | Keep::Tail(n) => n,
}
}
fn push(&mut self, bytes: &[u8]) {
self.total = self.total.saturating_add(bytes.len());
match self.keep {
Keep::Head(max) => {
let room = max.saturating_sub(self.kept.len());
self.kept.extend_from_slice(&bytes[..bytes.len().min(room)]);
}
Keep::Tail(max) => {
self.kept.extend_from_slice(bytes);
if self.kept.len() > max.saturating_mul(2) {
let cut = self.kept.len() - max;
self.kept.drain(..cut);
}
}
}
}
}
/// A pipe read to its end on its own thread. The thread signals `done` when
/// the pipe reaches end-of-file; the waiter never joins the thread.
struct PipeReader {
buf: std::sync::Arc<std::sync::Mutex<PipeBuf>>,
done: std::sync::mpsc::Receiver<()>,
}
impl PipeReader {
fn start<R: std::io::Read + Send + 'static>(pipe: Option<R>, keep: Keep) -> Self {
let buf = std::sync::Arc::new(std::sync::Mutex::new(PipeBuf {
keep,
kept: Vec::new(),
total: 0,
}));
let (tx, done) = std::sync::mpsc::channel();
match pipe {
Some(mut pipe) => {
let shared = std::sync::Arc::clone(&buf);
std::thread::spawn(move || {
let mut chunk = [0u8; 8192];
loop {
match pipe.read(&mut chunk) {
Ok(0) => break,
Ok(n) => shared
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(&chunk[..n]),
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(_) => break,
}
}
let _ = tx.send(());
});
}
None => {
let _ = tx.send(());
}
}
PipeReader { buf, done }
}
/// Whether the pipe reached end-of-file by `until`. A reader that has
/// finished (its sender is gone) counts as closed on every later call.
fn wait_until(&self, until: std::time::Instant) -> bool {
let wait = until.saturating_duration_since(std::time::Instant::now());
!matches!(
self.done.recv_timeout(wait),
Err(std::sync::mpsc::RecvTimeoutError::Timeout)
)
}
/// The kept bytes and whether output went past the bound and was dropped.
fn finish(self) -> (Vec<u8>, bool) {
let mut buf = self
.buf
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let max = buf.limit();
let truncated = buf.total > max;
if let Keep::Tail(_) = buf.keep {
if buf.kept.len() > max {
let cut = buf.kept.len() - max;
buf.kept.drain(..cut);
}
}
(std::mem::take(&mut buf.kept), truncated)
}
}
/// Wait for both pipes to close within one shared grace.
fn wait_pipes(out: &PipeReader, err: &PipeReader, grace: std::time::Duration) -> (bool, bool) {
let until = std::time::Instant::now() + grace;
(out.wait_until(until), err.wait_until(until))
}
/// Kill the child's tree, then reap the child. Unix: the child leads its own
/// process group, so the whole group is signalled. Windows: `taskkill /T` walks
/// the tree, which only works while the child is alive, so it runs first. Callers
/// on Windows terminate the run's job ([`kill_job`]) before this.
fn kill_tree(child: &mut std::process::Child) {
kill_group(child.id());
#[cfg(windows)]
{
taskkill_tree(child.id());
}
let _ = child.kill();
let _ = child.wait();
}
/// Windows: a job object that holds one bounded run's child and what it starts.
/// A process started by a job member joins the job (Windows 8 and later), so the
/// job reaches descendants whose parent has already exited, which `taskkill /T`
/// cannot walk. The job does not kill on close: a descendant that closed its
/// pipes and was left running survives, as on unix. A descendant the child starts
/// before it is assigned to the job escapes the job.
#[cfg(windows)]
struct TreeJob(*mut std::ffi::c_void);
#[cfg(windows)]
#[link(name = "kernel32")]
extern "system" {
fn CreateJobObjectW(attrs: *mut std::ffi::c_void, name: *const u16) -> *mut std::ffi::c_void;
fn AssignProcessToJobObject(job: *mut std::ffi::c_void, process: *mut std::ffi::c_void) -> i32;
fn TerminateJobObject(job: *mut std::ffi::c_void, exit_code: u32) -> i32;
fn CloseHandle(handle: *mut std::ffi::c_void) -> i32;
}
#[cfg(windows)]
impl TreeJob {
/// A job holding `child`, or `None` when no job can be made or the child
/// cannot join one (for example a process already in a job that forbids
/// nesting). Without a job the run falls back to `taskkill /T`.
fn for_child(child: &std::process::Child) -> Option<TreeJob> {
use std::os::windows::io::AsRawHandle;
// SAFETY: null attributes and a null name ask for an unnamed job with the
// default security descriptor; the call has no other effects.
let job = TreeJob(unsafe { CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()) });
if job.0.is_null() {
return None;
}
// SAFETY: `job.0` is the live job handle made above, and the child's
// process handle stays valid while `child` is borrowed. On failure `job`
// is dropped, which closes the job.
let assigned = unsafe { AssignProcessToJobObject(job.0, child.as_raw_handle()) };
(assigned != 0).then_some(job)
}
/// Kill every process in the job, the child's descendants included, whether
/// or not their parent is still alive.
fn terminate(&self) {
// SAFETY: `self.0` is a live job handle owned by `self`.
unsafe {
TerminateJobObject(self.0, 1);
}
}
}
#[cfg(windows)]
impl Drop for TreeJob {
fn drop(&mut self) {
// SAFETY: the handle came from CreateJobObjectW and is closed only here.
// Closing it ends nothing: kill-on-close is not set.
unsafe {
CloseHandle(self.0);
}
}
}
/// Windows: terminate a run's job, if it has one. Called before `taskkill /T` at
/// a deadline, and after a normal exit when a pipe is held open.
#[cfg(windows)]
fn kill_job(job: Option<&TreeJob>) {
if let Some(job) = job {
job.terminate();
}
}
/// Signal the process group whose id is `pid` (the child's own group on unix).
fn kill_group(pid: u32) {
#[cfg(unix)]
{
// SAFETY: a negative pid addresses the process group that
// `process_group(0)` made the child's own; kill has no memory effects.
unsafe {
libc::kill(-(pid as i32), libc::SIGKILL);
}
}
#[cfg(not(unix))]
{
let _ = pid;
}
}
/// `taskkill /T /F` on the tree rooted at `pid`, given at most
/// [`TASKKILL_LIMIT`]. It runs with argv (no shell) and its output is discarded.
#[cfg(windows)]
pub(crate) fn taskkill_tree(pid: u32) {
use std::process::Stdio;
let pid_text = pid.to_string();
let mut cmd = crate::safety::child_command("taskkill");
crate::safety::strip_ironshell_env(&mut cmd);
let Ok(mut killer) = cmd
.args(["/T", "/F", "/PID", pid_text.as_str()])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
else {
return;
};
let until = std::time::Instant::now() + TASKKILL_LIMIT;
loop {
match killer.try_wait() {
Ok(Some(_)) => return,
Ok(None) if std::time::Instant::now() < until => {
std::thread::sleep(std::time::Duration::from_millis(20));
}
_ => {
let _ = killer.kill();
let _ = killer.wait();
return;
}
}
}
}
/// Text with terminal colour sequences removed. Terraform colours some errors on
/// stderr even under -no-color, and a failure message should not carry them.
fn strip_ansi(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
if chars.peek() == Some(&'[') {
chars.next();
for d in chars.by_ref() {
if d.is_ascii_alphabetic() {
break;
}
}
}
} else {
out.push(c);
}
}
out
}
/// The text of a finished call, or the coded failure it reports.
fn tool_text(builtin: &str, run: ToolRun) -> Result<String> {
if !run.success {
return Err(crate::safety::tool_failed(
builtin,
&run.command,
run.code,
&run.stderr,
));
}
match run.keep {
Keep::Head(max) if run.stdout_truncated => Err(crate::safety::budget_exceeded(
builtin,
format!("`{}` printed more than {max} bytes", run.command),
"narrow the call so that it prints less",
)),
Keep::Tail(max) if run.stdout_truncated => Ok(format!(
"[earlier output dropped; the last {max} bytes follow]\n{}",
run.stdout
)),
_ => Ok(run.stdout),
}
}
/// The JSON a finished call printed. Output that is not JSON is a coded
/// failure, never a string handed back in place of the answer.
fn tool_json(builtin: &str, run: ToolRun) -> Result<Value> {
let command = run.command.clone();
let text = tool_text(builtin, run)?;
match serde_json::from_str::<serde_json::Value>(&text) {
Ok(json) => Ok(json_to_value(json)),
Err(e) => Err(crate::safety::tool_failed(
builtin,
&command,
Some(0),
&format!("printed output that is not JSON ({e})"),
)),
}
}
/// A helm call, run in the process's directory.
fn helm_call(builtin: &str, args: &[String], keep: Keep, deadline_secs: u64) -> Result<ToolRun> {
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
tool_run(builtin, "helm", None, &refs, keep, deadline_secs)
}
/// A terraform call, run in `dir`.
fn tf_call(
builtin: &str,
dir: &std::path::Path,
args: &[&str],
keep: Keep,
deadline_secs: u64,
) -> Result<ToolRun> {
tool_run(builtin, "terraform", Some(dir), args, keep, deadline_secs)
}
/// The workspace root as an absolute path. `IRONSHELL_WORKSPACE`, or the process's
/// directory when it is unset.
fn workspace_root_abs() -> std::path::PathBuf {
let root = crate::safety::workspace_root();
if root.is_absolute() {
root
} else {
std::env::current_dir()
.map(|cwd| cwd.join(&root))
.unwrap_or(root)
}
}
/// `value` resolved against `base` and required to lie inside the workspace.
/// Every path a helm or terraform call is given passes here first.
fn workspace_path(
builtin: &str,
value: &str,
base: &std::path::Path,
) -> Result<std::path::PathBuf> {
let given = std::path::Path::new(value);
let path = if given.is_absolute() {
given.to_path_buf()
} else {
base.join(given)
};
if !crate::safety::within_workspace(&path.to_string_lossy()) {
return Err(crate::safety::outside_workspace(builtin, value));
}
Ok(path)
}
/// A plain text argument: not empty and never option-like. Used where the
/// value goes to a tool as an argument, so a leading `-` would be a flag.
fn tf_plain(builtin: &str, what: &str, value: &str) -> Result<String> {
crate::safety::reject_option_like(builtin, &[value.to_string()])?;
if value.trim().is_empty() || value.contains('\0') {
return Err(crate::safety::arg_err(format!(
"{builtin}: {what} must not be empty"
)));
}
Ok(value.to_string())
}
/// A Terraform directory: the workspace root, or `value` inside it. Refused
/// when it is outside the workspace, or when it is not a directory. Without
/// that second check a missing directory would surface as E_TOOL_MISSING.
fn tf_dir(builtin: &str, value: Option<&str>) -> Result<std::path::PathBuf> {
let root = workspace_root_abs();
let dir = match value {
None => root,
Some(v) => workspace_path(builtin, v, &root)?,
};
if !dir.is_dir() {
return Err(crate::safety::not_found(
builtin,
"directory",
&dir.display().to_string(),
));
}
Ok(dir)
}
/// `-var-file=<path>` for a variable file inside the workspace, when one is given.
fn tf_var_file(builtin: &str, args: &[Value], index: usize) -> Result<Option<String>> {
let Some(name) = k8s_arg(builtin, "variable file", args, index, tf_plain)? else {
return Ok(None);
};
let path = workspace_path(builtin, &name, &workspace_root_abs())?;
if !path.is_file() {
return Err(crate::safety::not_found(builtin, "variable file", &name));
}
Ok(Some(format!("-var-file={}", path.display())))
}
/// A Terraform workspace name: letters, digits, `-` and `_`.
fn tf_workspace_name(builtin: &str, what: &str, value: &str) -> Result<String> {
crate::safety::reject_option_like(builtin, &[value.to_string()])?;
let well_formed = !value.is_empty()
&& value.chars().count() <= TF_MAX_WORKSPACE
&& value
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
if !well_formed {
return Err(crate::safety::arg_err(format!(
"{builtin}: {what} is 1-{TF_MAX_WORKSPACE} letters, digits, '-' or '_' (got {value:?})"
)));
}
Ok(value.to_string())
}
/// A helm chart: a repository chart name, an OCI or URL reference, or a local
/// path. A local path that exists must lie inside the workspace.
fn helm_chart(builtin: &str, what: &str, value: &str) -> Result<String> {
let chart = k8s_token(builtin, what, value)?;
if std::path::Path::new(&chart).exists() {
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
workspace_path(builtin, &chart, &cwd)?;
}
Ok(chart)
}
/// A helm values key: letters, digits, and `._-[]`, not starting with `-`.
fn helm_set_key(builtin: &str, key: &str) -> Result<String> {
let well_formed = !key.is_empty()
&& key.len() <= 253
&& !key.starts_with('-')
&& key
.chars()
.all(|c| c.is_ascii_alphanumeric() || "._-[]".contains(c));
if !well_formed {
return Err(crate::safety::arg_err(format!(
"{builtin}: a values key is letters, digits and ._-[] and does not start with '-' (got {key:?})"
)));
}
Ok(key.to_string())
}
/// `--set=key=value` for each override in a values Record. Each override is one
/// argv element, so a value cannot become a flag. A comma in a string value is
/// escaped, because helm splits `--set` on commas.
fn helm_set_flags(builtin: &str, values: Option<&Value>) -> Result<Vec<String>> {
let overrides = match values {
None => return Ok(Vec::new()),
Some(Value::Record(map)) => map,
Some(other) => {
return Err(crate::safety::arg_err(format!(
"{builtin}: values must be a Record, got {}",
other.type_name()
)))
}
};
if overrides.len() > HELM_MAX_SET {
return Err(crate::safety::arg_err(format!(
"{builtin}: at most {HELM_MAX_SET} values, got {}",
overrides.len()
)));
}
let mut flags = Vec::with_capacity(overrides.len());
for (key, value) in overrides {
let key = helm_set_key(builtin, key)?;
let text = match value {
Value::Str(s) if s.chars().any(char::is_control) => {
return Err(crate::safety::arg_err(format!(
"{builtin}: the value for {key} contains a control character"
)))
}
Value::Str(s) => s.replace(',', "\\,"),
Value::Int(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
Value::Float(f) if f.is_finite() => f.to_string(),
other => {
return Err(crate::safety::arg_err(format!(
"{builtin}: the value for {key} must be a String, Int, Float or Bool, got {}",
other.type_name()
)))
}
};
flags.push(format!("--set={key}={text}"));
}
Ok(flags)
}
/// `--namespace=<ns>` when a namespace was given.
fn helm_ns_flag(ns: Option<String>) -> Option<String> {
ns.map(|n| format!("--namespace={n}"))
}
// Helper: parse tabular kubectl output (e.g. `kubectl top`) into Records
fn parse_kubectl_table(text: &str) -> Vec<Value> {
let mut lines = text.lines();
let header_line = match lines.next() {
Some(h) => h,
None => return Vec::new(),
};
let headers: Vec<String> = header_line
.split_whitespace()
.map(|h| {
h.to_lowercase()
.replace('(', "_")
.replace(')', "")
.replace('%', "pct")
})
.collect();
let mut rows = Vec::new();
for line in lines {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let cols: Vec<&str> = trimmed.split_whitespace().collect();
let mut record = std::collections::BTreeMap::new();
for (i, hdr) in headers.iter().enumerate() {
let val = cols.get(i).unwrap_or(&"").to_string();
record.insert(hdr.clone(), Value::Str(val));
}
rows.push(Value::Record(record));
}
rows
}
// ---------------------------------------------------------------------------
// 850: bi_k8s_get - kubectl get <resource> [-n namespace] -o json
// ---------------------------------------------------------------------------
fn bi_k8s_get(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("k8s_get", &input)?;
k8s_max_args("k8s_get", &args, 2)?;
let resource = k8s_need("k8s_get", "a resource type", &args, 0, k8s_token)?;
let ns = k8s_arg("k8s_get", "namespace", &args, 1, k8s_object_name)?;
let mut cmd: Vec<&str> = vec!["get", "-o", "json"];
k8s_push_ns(&mut cmd, ns.as_deref());
cmd.extend(["--", resource.as_str()]);
kubectl_json("k8s_get", &cmd, K8S_MAX_JSON)
}
// ---------------------------------------------------------------------------
// 851: bi_k8s_apply - kubectl apply -f <local manifest> [-n namespace]
//
// The manifest is a local file inside the workspace, read through the same
// guard as the file builtins before kubectl is given its resolved path. A URL
// is refused: kubectl would fetch it outside that guard.
// ---------------------------------------------------------------------------
fn bi_k8s_apply(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "k8s_apply";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 2)?;
let Some(Value::Str(file)) = args.first() else {
return Err(crate::safety::arg_err(
"k8s_apply requires a manifest file path as first argument",
));
};
crate::safety::reject_option_like(B, std::slice::from_ref(file))?;
if file.contains("://") {
return Err(crate::safety::arg_err(
"k8s_apply reads a local manifest; a URL is not fetched, because kubectl would read it outside the workspace guard",
));
}
let ns = k8s_arg(B, "namespace", &args, 1, k8s_object_name)?;
let resolved = crate::safety::resolve_path_str(file);
// No guard call here: listing k8s_apply as self-guarded would skip the
// dispatcher's Network guard (metering, allowlist, audit) for the cluster
// call. The manifest read is checked by validate_read_path.
let path = crate::security::validate_read_path(&resolved)?;
let meta = std::fs::metadata(&path).map_err(|e| crate::safety::fs_error(B, &path, &e))?;
if !meta.is_file() {
return Err(crate::safety::arg_err(format!(
"{B} requires a regular file, got {resolved:?}"
)));
}
let path_text = path.display().to_string();
let mut cmd: Vec<&str> = vec!["apply", "-f", path_text.as_str()];
k8s_push_ns(&mut cmd, ns.as_deref());
let text = kubectl_run(B, &cmd, K8S_MAX_TEXT)?;
Ok(Value::Str(text.trim().to_string()))
}
// ---------------------------------------------------------------------------
// 852: bi_k8s_delete - kubectl delete <resource> <name> [-n namespace]
// ---------------------------------------------------------------------------
fn bi_k8s_delete(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "k8s_delete";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 3)?;
let resource = k8s_need(B, "a resource type", &args, 0, k8s_token)?;
let name = k8s_need(B, "a resource name", &args, 1, k8s_object_name)?;
let ns = k8s_arg(B, "namespace", &args, 2, k8s_object_name)?;
crate::safety::guard(crate::safety::GuardCtx {
builtin: B,
effect: crate::safety::Effect::Destructive,
what: "k8s_delete",
targets: vec![format!("{}/{}", resource, name)],
blast_radius: serde_json::json!({ "resource": resource, "name": name, "namespace": ns }),
reversible: false,
fs_paths: false,
})?;
let mut cmd: Vec<&str> = vec!["delete"];
k8s_push_ns(&mut cmd, ns.as_deref());
cmd.extend(["--", resource.as_str(), name.as_str()]);
let text = kubectl_run(B, &cmd, K8S_MAX_TEXT)?;
Ok(Value::Str(text.trim().to_string()))
}
// ---------------------------------------------------------------------------
// 853: bi_k8s_describe - kubectl describe <resource> <name> [-n namespace]
// ---------------------------------------------------------------------------
fn bi_k8s_describe(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "k8s_describe";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 3)?;
let resource = k8s_need(B, "a resource type", &args, 0, k8s_token)?;
let name = k8s_need(B, "a resource name", &args, 1, k8s_object_name)?;
let ns = k8s_arg(B, "namespace", &args, 2, k8s_object_name)?;
let mut cmd: Vec<&str> = vec!["describe"];
k8s_push_ns(&mut cmd, ns.as_deref());
cmd.extend(["--", resource.as_str(), name.as_str()]);
let text = kubectl_run(B, &cmd, K8S_MAX_TEXT)?;
Ok(Value::Str(text.trim().to_string()))
}
// ---------------------------------------------------------------------------
// 854: bi_k8s_logs - kubectl logs <pod> [-n namespace] --tail=N
//
// Always bounded: the tail defaults to K8S_LOG_DEFAULT_TAIL lines and cannot
// exceed K8S_LOG_MAX_TAIL.
// ---------------------------------------------------------------------------
fn bi_k8s_logs(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "k8s_logs";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 3)?;
let pod = k8s_need(B, "a pod name", &args, 0, k8s_object_name)?;
let ns = k8s_arg(B, "namespace", &args, 1, k8s_object_name)?;
let tail = match args.get(2) {
None => K8S_LOG_DEFAULT_TAIL,
Some(Value::Int(n)) if (1..=K8S_LOG_MAX_TAIL).contains(n) => *n,
Some(Value::Int(n)) => {
return Err(crate::safety::arg_err(format!(
"k8s_logs tail must be in 1..={K8S_LOG_MAX_TAIL} lines, got {n}"
)))
}
Some(other) => {
return Err(crate::safety::arg_err(format!(
"k8s_logs tail must be an Int, got {}",
other.type_name()
)))
}
};
let tail_flag = format!("--tail={tail}");
let mut cmd: Vec<&str> = vec!["logs", tail_flag.as_str()];
k8s_push_ns(&mut cmd, ns.as_deref());
cmd.extend(["--", pod.as_str()]);
Ok(Value::Str(kubectl_run(B, &cmd, K8S_MAX_TEXT)?))
}
// ---------------------------------------------------------------------------
// 855: bi_k8s_exec - kubectl exec <pod> [-n namespace] -- <argv...>
//
// The command is an Array of Strings, the argv the container runs. A string is
// refused rather than split on spaces, and nothing is run through a shell.
// ---------------------------------------------------------------------------
fn bi_k8s_exec(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "k8s_exec";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 3)?;
let pod = k8s_need(B, "a pod name", &args, 0, k8s_object_name)?;
let Some(Value::Array(items)) = args.get(1) else {
return Err(crate::safety::arg_err(
"k8s_exec command must be an Array of Strings (the argv); a shell string is not run",
));
};
if items.is_empty() || items.len() > K8S_MAX_EXEC_ARGV {
return Err(crate::safety::arg_err(format!(
"k8s_exec command must have 1..={K8S_MAX_EXEC_ARGV} elements, got {}",
items.len()
)));
}
let mut argv: Vec<String> = Vec::with_capacity(items.len());
for item in items {
match item {
Value::Str(s) if !s.contains('\0') => argv.push(s.clone()),
_ => {
return Err(crate::safety::arg_err(
"k8s_exec command elements must be Strings without NUL bytes",
))
}
}
}
let ns = k8s_arg(B, "namespace", &args, 2, k8s_object_name)?;
crate::safety::guard_exec(B, format!("kubectl exec {pod} -- {}", argv.join(" ")))?;
let mut cmd: Vec<&str> = vec!["exec", pod.as_str()];
k8s_push_ns(&mut cmd, ns.as_deref());
cmd.push("--");
cmd.extend(argv.iter().map(String::as_str));
Ok(Value::Str(kubectl_run(B, &cmd, K8S_MAX_TEXT)?))
}
// ---------------------------------------------------------------------------
// 856-866: list builtins (kubectl get <resource> -o json)
// ---------------------------------------------------------------------------
fn bi_k8s_pods(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_list("k8s_pods", "pods", true, &args, input)
}
fn bi_k8s_services(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_list("k8s_services", "services", true, &args, input)
}
fn bi_k8s_deployments(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_list("k8s_deployments", "deployments", true, &args, input)
}
fn bi_k8s_namespaces(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_list("k8s_namespaces", "namespaces", false, &args, input)
}
fn bi_k8s_nodes(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_list("k8s_nodes", "nodes", false, &args, input)
}
fn bi_k8s_events(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_list("k8s_events", "events", true, &args, input)
}
fn bi_k8s_configmaps(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_list("k8s_configmaps", "configmaps", true, &args, input)
}
fn bi_k8s_ingresses(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_list("k8s_ingresses", "ingresses", true, &args, input)
}
// ---------------------------------------------------------------------------
// 863: bi_k8s_secrets - secret names only
//
// The jsonpath selects metadata.name and nothing else, so no secret value is
// read into the shell.
// ---------------------------------------------------------------------------
fn bi_k8s_secrets(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "k8s_secrets";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 1)?;
let ns = k8s_arg(B, "namespace", &args, 0, k8s_object_name)?;
let mut cmd: Vec<&str> = vec!["get", "-o", "jsonpath={.items[*].metadata.name}"];
k8s_push_ns(&mut cmd, ns.as_deref());
cmd.extend(["--", "secrets"]);
let text = kubectl_run(B, &cmd, K8S_MAX_JSON)?;
let names: Vec<Value> = text
.split_whitespace()
.map(|s| Value::Str(s.to_string()))
.collect();
Ok(Value::Array(names))
}
// ---------------------------------------------------------------------------
// 865-866: bi_k8s_rollout_status / bi_k8s_rollout_restart
//
// `rollout status` is given its own deadline, so kubectl ends a rollout that
// never completes with a coded failure before the call's deadline kills it.
// ---------------------------------------------------------------------------
fn bi_k8s_rollout_status(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "k8s_rollout_status";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 2)?;
let name = k8s_need(B, "a deployment name", &args, 0, k8s_object_name)?;
let ns = k8s_arg(B, "namespace", &args, 1, k8s_object_name)?;
let target = format!("deployment/{name}");
let mut cmd: Vec<&str> = vec!["rollout", "status", "--timeout=25s"];
k8s_push_ns(&mut cmd, ns.as_deref());
cmd.extend(["--", target.as_str()]);
let text = kubectl_run(B, &cmd, K8S_MAX_TEXT)?;
Ok(Value::Str(text.trim().to_string()))
}
fn bi_k8s_rollout_restart(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "k8s_rollout_restart";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 2)?;
let name = k8s_need(B, "a deployment name", &args, 0, k8s_object_name)?;
let ns = k8s_arg(B, "namespace", &args, 1, k8s_object_name)?;
let target = format!("deployment/{name}");
let mut cmd: Vec<&str> = vec!["rollout", "restart"];
k8s_push_ns(&mut cmd, ns.as_deref());
cmd.extend(["--", target.as_str()]);
let text = kubectl_run(B, &cmd, K8S_MAX_TEXT)?;
Ok(Value::Str(text.trim().to_string()))
}
// ---------------------------------------------------------------------------
// 867: bi_k8s_scale - kubectl scale deployment/<name> --replicas=N [-n ns]
// ---------------------------------------------------------------------------
fn bi_k8s_scale(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "k8s_scale";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 3)?;
let name = k8s_need(B, "a deployment name", &args, 0, k8s_object_name)?;
let replicas = match args.get(1) {
Some(Value::Int(n)) if (0..=K8S_MAX_REPLICAS).contains(n) => *n,
Some(Value::Int(n)) => {
return Err(crate::safety::arg_err(format!(
"k8s_scale replicas must be in 0..={K8S_MAX_REPLICAS}, got {n}"
)))
}
Some(other) => {
return Err(crate::safety::arg_err(format!(
"k8s_scale replicas must be an Int, got {}",
other.type_name()
)))
}
None => {
return Err(crate::safety::arg_err(
"k8s_scale requires a replica count as second argument",
))
}
};
let ns = k8s_arg(B, "namespace", &args, 2, k8s_object_name)?;
let target = format!("deployment/{name}");
let replicas_flag = format!("--replicas={replicas}");
let mut cmd: Vec<&str> = vec!["scale", replicas_flag.as_str()];
k8s_push_ns(&mut cmd, ns.as_deref());
cmd.extend(["--", target.as_str()]);
let text = kubectl_run(B, &cmd, K8S_MAX_TEXT)?;
Ok(Value::Str(text.trim().to_string()))
}
// ---------------------------------------------------------------------------
// 868: bi_k8s_top_pods - kubectl top pods [-n namespace] (parsed table)
// ---------------------------------------------------------------------------
fn bi_k8s_top_pods(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "k8s_top_pods";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 1)?;
let ns = k8s_arg(B, "namespace", &args, 0, k8s_object_name)?;
let mut cmd: Vec<&str> = vec!["top", "pods"];
k8s_push_ns(&mut cmd, ns.as_deref());
let text = kubectl_run(B, &cmd, K8S_MAX_TEXT)?;
Ok(Value::Array(parse_kubectl_table(&text)))
}
// ---------------------------------------------------------------------------
// 869: bi_k8s_top_nodes - kubectl top nodes (parsed table)
// ---------------------------------------------------------------------------
fn bi_k8s_top_nodes(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("k8s_top_nodes", &input)?;
k8s_max_args("k8s_top_nodes", &args, 0)?;
let text = kubectl_run("k8s_top_nodes", &["top", "nodes"], K8S_MAX_TEXT)?;
Ok(Value::Array(parse_kubectl_table(&text)))
}
// ---------------------------------------------------------------------------
// 870: bi_k8s_context - current-context (no argument) or use-context (a name)
// ---------------------------------------------------------------------------
fn bi_k8s_context(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "k8s_context";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 1)?;
let text = match k8s_arg(B, "a context name", &args, 0, k8s_token)? {
Some(ctx) => kubectl_run(
B,
&["config", "use-context", "--", ctx.as_str()],
K8S_MAX_TEXT,
)?,
None => kubectl_run(B, &["config", "current-context"], K8S_MAX_TEXT)?,
};
Ok(Value::Str(text.trim().to_string()))
}
// ---------------------------------------------------------------------------
// 871: bi_k8s_contexts - kubectl config get-contexts -o name
// ---------------------------------------------------------------------------
fn bi_k8s_contexts(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("k8s_contexts", &input)?;
k8s_max_args("k8s_contexts", &args, 0)?;
let text = kubectl_run(
"k8s_contexts",
&["config", "get-contexts", "-o", "name"],
K8S_MAX_TEXT,
)?;
let contexts: Vec<Value> = text
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(|l| Value::Str(l.to_string()))
.collect();
Ok(Value::Array(contexts))
}
// ---------------------------------------------------------------------------
// 872: bi_k8s_cluster_info - kubectl cluster-info
// ---------------------------------------------------------------------------
fn bi_k8s_cluster_info(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("k8s_cluster_info", &input)?;
k8s_max_args("k8s_cluster_info", &args, 0)?;
let text = kubectl_run("k8s_cluster_info", &["cluster-info"], K8S_MAX_TEXT)?;
Ok(Value::Str(text.trim().to_string()))
}
// ---------------------------------------------------------------------------
// 873: bi_helm_list - helm list [namespace] -o json
// ---------------------------------------------------------------------------
fn bi_helm_list(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "helm_list";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 1)?;
let ns = k8s_arg(B, "namespace", &args, 0, k8s_object_name)?;
let mut cmd: Vec<String> = vec!["list".into(), "-o".into(), "json".into()];
cmd.extend(helm_ns_flag(ns));
let run = helm_call(B, &cmd, Keep::Head(TOOL_MAX_JSON), TOOL_DEADLINE_SECS)?;
tool_json(B, run)
}
// ---------------------------------------------------------------------------
// 874 and 875: bi_helm_install / bi_helm_upgrade - helm <verb> [flags] -- release chart
// ---------------------------------------------------------------------------
fn helm_deploy(builtin: &str, verb: &str, args: &[Value], input: &Option<Value>) -> Result<Value> {
k8s_no_input(builtin, input)?;
k8s_max_args(builtin, args, 4)?;
crate::safety::check_egress_unknown(
builtin,
"the chart's repository is named by helm's repo configuration, not the arguments",
)?;
let release = k8s_need(builtin, "a release name", args, 0, k8s_object_name)?;
let chart = k8s_need(builtin, "a chart", args, 1, helm_chart)?;
let ns = k8s_arg(builtin, "namespace", args, 2, k8s_object_name)?;
let sets = helm_set_flags(builtin, args.get(3))?;
let mut cmd: Vec<String> = vec![verb.into()];
cmd.extend(helm_ns_flag(ns));
cmd.extend(sets);
cmd.extend(["--".into(), release, chart]);
let run = helm_call(
builtin,
&cmd,
Keep::Tail(TOOL_KEEP_TAIL),
TOOL_LONG_DEADLINE_SECS,
)?;
Ok(Value::Str(tool_text(builtin, run)?.trim().to_string()))
}
/// 874 – bi_helm_install: helm install <release> <chart> [namespace] [values]
fn bi_helm_install(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
helm_deploy("helm_install", "install", &args, &input)
}
/// 875 – bi_helm_upgrade: helm upgrade <release> <chart> [namespace] [values]
fn bi_helm_upgrade(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
helm_deploy("helm_upgrade", "upgrade", &args, &input)
}
// ---------------------------------------------------------------------------
// 876: bi_helm_uninstall - helm uninstall [namespace] -- release
// ---------------------------------------------------------------------------
fn bi_helm_uninstall(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "helm_uninstall";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 2)?;
let release = k8s_need(B, "a release name", &args, 0, k8s_object_name)?;
let ns = k8s_arg(B, "namespace", &args, 1, k8s_object_name)?;
let mut cmd: Vec<String> = vec!["uninstall".into()];
cmd.extend(helm_ns_flag(ns));
cmd.extend(["--".into(), release]);
let run = helm_call(B, &cmd, Keep::Tail(TOOL_KEEP_TAIL), TOOL_LONG_DEADLINE_SECS)?;
Ok(Value::Str(tool_text(B, run)?.trim().to_string()))
}
// ---------------------------------------------------------------------------
// 877: bi_helm_repos - helm repo list -o json
// ---------------------------------------------------------------------------
fn bi_helm_repos(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "helm_repos";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 0)?;
let cmd: Vec<String> = vec!["repo".into(), "list".into(), "-o".into(), "json".into()];
let run = helm_call(B, &cmd, Keep::Head(TOOL_MAX_JSON), TOOL_DEADLINE_SECS)?;
// With no repository file helm exits 1 to say there is nothing to list.
// That is an empty answer, not a failure.
if !run.success && run.stderr.contains("no repositories to show") {
return Ok(Value::Array(Vec::new()));
}
tool_json(B, run)
}
// ---------------------------------------------------------------------------
// 878: bi_helm_search - helm search repo <keyword> -o json
// ---------------------------------------------------------------------------
fn bi_helm_search(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "helm_search";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 1)?;
let keyword = k8s_need(B, "a keyword", &args, 0, k8s_token)?;
// `search repo` reads the index of the repositories already added. It does
// not contact the Artifact Hub; that search is deliberately not offered.
let cmd: Vec<String> = vec![
"search".into(),
"repo".into(),
"-o".into(),
"json".into(),
"--".into(),
keyword,
];
let run = helm_call(B, &cmd, Keep::Head(TOOL_MAX_JSON), TOOL_DEADLINE_SECS)?;
tool_json(B, run)
}
// ---------------------------------------------------------------------------
// 879: bi_helm_status - helm status [namespace] -o json -- release
// ---------------------------------------------------------------------------
fn bi_helm_status(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "helm_status";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 2)?;
let release = k8s_need(B, "a release name", &args, 0, k8s_object_name)?;
let ns = k8s_arg(B, "namespace", &args, 1, k8s_object_name)?;
let mut cmd: Vec<String> = vec!["status".into(), "-o".into(), "json".into()];
cmd.extend(helm_ns_flag(ns));
cmd.extend(["--".into(), release]);
let run = helm_call(B, &cmd, Keep::Head(TOOL_MAX_JSON), TOOL_DEADLINE_SECS)?;
tool_json(B, run)
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Run a command, return stdout on success or an error with stderr.
fn vm_run_cmd(program: &str, args: &[&str]) -> Result<String> {
let mut cmd = crate::safety::child_command(program);
cmd.args(args);
// A deadline is a budget, not a missing tool, so it keeps its own code.
let output = crate::safety::output_with_timeout(cmd, TOOL_DEADLINE_SECS).map_err(|e| {
match crate::safety::io_budget_error(&e) {
Some(coded) => coded,
None if e.kind() == std::io::ErrorKind::TimedOut => {
crate::safety::spawn_error(program, program, &e)
}
None => crate::safety::tool_missing(program, program, &e.to_string()),
}
})?;
if !output.status.success() {
return Err(crate::safety::tool_failed(
program,
&format!("{} {}", program, args.join(" ")),
output.status.code(),
&String::from_utf8_lossy(&output.stderr),
));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
/// Run a command, parse stdout as JSON, and convert to Value.
fn vm_run_cmd_json(program: &str, args: &[&str]) -> Result<Value> {
let text = vm_run_cmd(program, args)?;
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
Ok(json_to_value(json))
} else {
Ok(Value::Str(text.trim().to_string()))
}
}
/// Run a PowerShell command and return stdout.
fn vm_run_powershell(script: &str) -> Result<String> {
vm_run_cmd("powershell", &["-NoProfile", "-Command", script])
}
/// Run a PowerShell command that produces JSON and parse it.
fn vm_run_powershell_json(script: &str) -> Result<Value> {
let text = vm_run_powershell(script)?;
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
Ok(json_to_value(json))
} else {
Ok(Value::Str(text.trim().to_string()))
}
}
/// Parse virsh / tabular output into Vec<Value::Record>.
fn parse_virsh_table(text: &str) -> Vec<Value> {
let mut lines: Vec<&str> = text.lines().collect();
// virsh usually has a header line, then a separator "---...", then data
if lines.len() < 2 {
return Vec::new();
}
// Find header
let header_line = lines.remove(0).trim();
let headers: Vec<String> = header_line
.split_whitespace()
.map(|h| h.to_lowercase())
.collect();
// Skip separator line(s) that start with dashes
while !lines.is_empty() && lines[0].trim().starts_with('-') {
lines.remove(0);
}
let mut rows = Vec::new();
for line in &lines {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let cols: Vec<&str> = trimmed
.splitn(headers.len(), char::is_whitespace)
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
let mut record = std::collections::BTreeMap::new();
for (i, hdr) in headers.iter().enumerate() {
let val = cols.get(i).unwrap_or(&"").to_string();
record.insert(hdr.clone(), Value::Str(val));
}
rows.push(Value::Record(record));
}
rows
}
/// Parse WSL --list --verbose tabular output into Vec<Value::Record>.
fn parse_wsl_list(text: &str) -> Vec<Value> {
let mut lines: Vec<&str> = text.lines().collect();
if lines.is_empty() {
return Vec::new();
}
// First line is header: " NAME STATE VERSION"
lines.remove(0); // skip header
let mut rows = Vec::new();
for line in &lines {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
// Default distro has a leading '*'
let cleaned = trimmed.trim_start_matches('*').trim();
let cols: Vec<&str> = cleaned.split_whitespace().collect();
if cols.len() >= 3 {
let mut record = std::collections::BTreeMap::new();
record.insert("name".to_string(), Value::Str(cols[0].to_string()));
record.insert("state".to_string(), Value::Str(cols[1].to_string()));
record.insert("version".to_string(), Value::Str(cols[2].to_string()));
record.insert("default".to_string(), Value::Bool(trimmed.starts_with('*')));
rows.push(Value::Record(record));
}
}
rows
}
/// Require a string argument at the given position.
fn require_str(args: &[Value], idx: usize, name: &str) -> Result<String> {
match args.get(idx) {
Some(Value::Str(s)) => Ok(s.clone()),
_ => Err(crate::safety::arg_err(format!(
"{} requires a string argument",
name
))),
}
}
/// Whether a detection probe's tool ran and exited 0. A tool that is not
/// installed is `false`. A probe that could not run (a spawn failure, or no
/// answer before the deadline) is an error: it used to fall through to "none",
/// which is a claim that no hypervisor exists.
fn hypervisor_probe_ok(tool: &str, cmd: std::process::Command) -> Result<bool> {
match bounded_cmd_output("detect_hypervisor", tool, cmd, PROBE_DEADLINE_SECS) {
Ok(o) => Ok(o.status.success()),
Err(e) if is_tool_missing(&e) => Ok(false),
Err(e) => Err(e),
}
}
/// Detect available hypervisor on the current platform.
/// Returns one of: "hyperv", "virsh", "vboxmanage", "none"
fn detect_hypervisor() -> Result<&'static str> {
#[cfg(target_os = "windows")]
{
// On Windows, prefer Hyper-V
let mut cmd = crate::safety::child_command("powershell");
cmd.args([
"-NoProfile",
"-Command",
"Get-Command Get-VM -ErrorAction SilentlyContinue",
]);
if hypervisor_probe_ok("powershell", cmd)? {
return Ok("hyperv");
}
}
// Try virsh (Linux / macOS with libvirt)
let mut cmd = crate::safety::child_command("virsh");
cmd.arg("version");
if hypervisor_probe_ok("virsh", cmd)? {
return Ok("virsh");
}
// Try VBoxManage
let mut cmd = crate::safety::child_command("VBoxManage");
cmd.arg("--version");
if hypervisor_probe_ok("VBoxManage", cmd)? {
return Ok("vboxmanage");
}
Ok("none")
}
// ============================================================================
// Cloud & Infrastructure-as-Code Builtins (920-949)
// Terraform, Ansible, Pulumi, Vagrant, Packer, AWS, Azure, GCloud
// ============================================================================
// [removed duplicate] use std::collections::BTreeMap;
// [removed duplicate] use std::process::Command;
// [removed duplicate] use anyhow::{anyhow, Result};
// [removed duplicate] use crate::value::Value;
// [removed duplicate] use crate::builtins::json_to_value;
// ---------------------------------------------------------------------------
// Ansible, cloud, container-scan, forge (gh, glab), rsync and package helpers.
// Every value a tool receives as an argument passes one of these first, and a
// call that does not match is refused before any process starts.
// ---------------------------------------------------------------------------
/// The longest image, transport or remote reference a builtin passes on.
const OPS_MAX_REF: usize = 512;
/// The longest event or subcommand name.
const OPS_MAX_NAME: usize = 64;
/// The longest command string a remote node would be sent.
const OPS_MAX_COMMAND: usize = 8192;
/// The transports skopeo may copy from or inspect. Each names a registry or a
/// local store; none runs a program.
const OPS_TRANSPORTS: &[&str] = &[
"docker://",
"docker-daemon:",
"containers-storage:",
"docker-archive:",
"oci-archive:",
"oci:",
"dir:",
];
/// The transports whose reference is a local path, which must lie in the workspace.
const OPS_LOCAL_TRANSPORTS: &[&str] = &["docker-archive:", "oci-archive:", "oci:", "dir:"];
/// Forge flags that open an editor or a browser. A call here must not open either.
const OPS_FORGE_REFUSED_FLAGS: &[&str] = &["--editor", "-e", "--web", "-w", "--browser"];
/// Forge subcommands that change a remote.
const OPS_FORGE_WRITE_VERBS: &[&str] = &[
"close", "comment", "create", "edit", "note", "ready", "reopen", "update",
];
/// The subcommands `gh pr` accepts. Merge, review and checkout are not offered.
const GH_PR_VERBS: &[&str] = &[
"checks", "close", "comment", "create", "diff", "edit", "list", "ready", "reopen", "status",
"view",
];
/// The subcommands `gh issue` accepts. Delete, transfer, pin and lock are not offered.
const GH_ISSUE_VERBS: &[&str] = &[
"close", "comment", "create", "edit", "list", "reopen", "status", "view",
];
/// The subcommands `gh repo` accepts. Every one that changes a repository is refused.
const GH_REPO_VERBS: &[&str] = &["list", "view"];
/// The subcommands `glab mr` accepts. Merge and approve are not offered.
const GLAB_MR_VERBS: &[&str] = &[
"close", "create", "diff", "list", "note", "reopen", "update", "view",
];
/// The subcommands `glab issue` accepts. Delete is not offered.
const GLAB_ISSUE_VERBS: &[&str] = &[
"close", "create", "list", "note", "reopen", "update", "view",
];
/// A cloud call's error for an operation no provider client backs.
fn cloud_unimplemented(builtin: &str) -> anyhow::Error {
crate::safety::unimplemented(
builtin,
"no cloud provider is wired into IronShell, so nothing was created, changed or read",
"use the provider's own CLI or API",
)
}
/// The Record a cloud builtin takes as options: each key one it names, each
/// value a String. Anything else is refused rather than dropped.
fn cloud_options(builtin: &str, value: Option<&Value>, keys: &[&str]) -> Result<()> {
let opts = match value {
None => return Ok(()),
Some(Value::Record(map)) => map,
Some(other) => {
return Err(crate::safety::arg_err(format!(
"{builtin}: options must be a Record, got {}",
other.type_name()
)))
}
};
for (key, value) in opts {
if !keys.contains(&key.as_str()) {
return Err(crate::safety::arg_err(format!(
"{builtin}: unknown option {key:?}; the options are {}",
keys.join(", ")
)));
}
match value {
Value::Str(s) if !s.contains('\0') => {}
other => {
return Err(crate::safety::arg_err(format!(
"{builtin}: option {key} must be a String, got {}",
other.type_name()
)))
}
}
}
Ok(())
}
/// A container image reference: letters, digits and `._-/:@+`, never a leading
/// `-`. The set has no spaces, quotes or separators a tool would split on.
fn ops_image_ref(builtin: &str, what: &str, value: &str) -> Result<String> {
let reference = pkg_plain(builtin, what, value)?;
let well_formed = reference.len() <= OPS_MAX_REF
&& reference
.chars()
.all(|c| c.is_ascii_alphanumeric() || "._-/:@+".contains(c));
if !well_formed {
return Err(crate::safety::arg_err(format!(
"{builtin}: {what} is letters, digits and ._-/:@+, at most {OPS_MAX_REF} characters (got {reference:?})"
)));
}
Ok(reference)
}
/// A skopeo reference that begins with a transport. A local transport's path
/// must lie inside the workspace; a registry or daemon reference is checked as
/// an image reference.
fn ops_transport(builtin: &str, what: &str, value: &str) -> Result<String> {
let text = pkg_plain(builtin, what, value)?;
if text.len() > OPS_MAX_REF {
return Err(crate::safety::arg_err(format!(
"{builtin}: {what} is longer than {OPS_MAX_REF} characters"
)));
}
let Some(&prefix) = OPS_TRANSPORTS.iter().find(|p| text.starts_with(**p)) else {
return Err(crate::safety::arg_err(format!(
"{builtin}: {what} {text:?} must name a transport: docker://, docker-daemon:, containers-storage:, docker-archive:, oci-archive:, oci: or dir:"
)));
};
let rest = &text[prefix.len()..];
if OPS_LOCAL_TRANSPORTS.contains(&prefix) {
// `oci:path:tag`: a colon past a drive letter (index 1) starts the tag.
let path = match rest.rfind(':') {
Some(at) if at > 1 => &rest[..at],
_ => rest,
};
if path.is_empty() {
return Err(crate::safety::arg_err(format!(
"{builtin}: {what} {text:?} names no path"
)));
}
pkg_inside(builtin, path)?;
} else {
ops_image_ref(builtin, what, rest)?;
}
Ok(text)
}
/// A local path an ops tool reads or writes: a plain value, never a URL, and
/// inside the workspace.
fn ops_local_path(builtin: &str, what: &str, value: &str) -> Result<String> {
let path = pkg_plain(builtin, what, value)?;
if path.contains("://") {
return Err(crate::safety::arg_err(format!(
"{builtin}: {what} {path:?} is a URL; name a local path inside the workspace"
)));
}
pkg_inside(builtin, &path)?;
Ok(path)
}
/// A local path that must exist: a file, or a directory when `file` is false.
fn ops_existing_local(builtin: &str, what: &str, value: &str, file: bool) -> Result<String> {
let path = ops_local_path(builtin, what, value)?;
let base = std::env::current_dir().unwrap_or_else(|_| workspace_root_abs());
let full = workspace_path(builtin, &path, &base)?;
let present = if file { full.is_file() } else { full.exists() };
if !present {
return Err(crate::safety::not_found(builtin, what, &path));
}
Ok(path)
}
/// A playbook: an existing file inside the workspace.
fn ops_playbook(builtin: &str, what: &str, value: &str) -> Result<String> {
ops_existing_local(builtin, what, value, true)
}
/// An inventory: an existing file or directory inside the workspace. An inline
/// host list such as `host1,` is not a path and does not exist, so it is refused.
fn ops_inventory(builtin: &str, what: &str, value: &str) -> Result<String> {
ops_existing_local(builtin, what, value, false)
}
/// A vault file: an existing file inside the workspace.
fn ops_vault_file(builtin: &str, what: &str, value: &str) -> Result<String> {
ops_existing_local(builtin, what, value, true)
}
/// A directory to scan: an existing path inside the workspace.
fn ops_scan_dir(builtin: &str, what: &str, value: &str) -> Result<String> {
ops_existing_local(builtin, what, value, false)
}
/// The action `ansible-vault` runs. Only the three named actions are passed on.
fn ops_vault_action(builtin: &str, what: &str, value: &str) -> Result<String> {
match value {
"view" | "encrypt" | "decrypt" => Ok(value.to_string()),
other => Err(crate::safety::arg_err(format!(
"{builtin}: {what} must be view, encrypt or decrypt (got {other:?})"
))),
}
}
/// An event or workflow name: letters, digits, `_` and `-`.
fn ops_event(builtin: &str, what: &str, value: &str) -> Result<String> {
let event = pkg_plain(builtin, what, value)?;
if event.len() > OPS_MAX_NAME
|| !event
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Err(crate::safety::arg_err(format!(
"{builtin}: {what} is letters, digits, '_' and '-', at most {OPS_MAX_NAME} characters (got {event:?})"
)));
}
Ok(event)
}
/// Whether an rsync operand names a remote host: a colon before any separator,
/// other than a drive letter's colon at index 1.
fn ops_is_remote(value: &str) -> bool {
let cut = value.find(['/', '\\']).unwrap_or(value.len());
value[..cut].find(':').is_some_and(|at| at > 1)
}
/// An rsync operand: a local path inside the workspace, or `[user@]host:path` /
/// `host::module` with a plain host name. A URL is refused, and so is any host
/// that begins with `-`, which ssh would read as an option.
fn ops_sync_path(builtin: &str, what: &str, value: &str) -> Result<String> {
let text = pkg_plain(builtin, what, value)?;
if text.contains("://") {
return Err(crate::safety::arg_err(format!(
"{builtin}: {what} {text:?} is a URL; rsync reaches a host as [user@]host:path"
)));
}
if !ops_is_remote(&text) {
return ops_local_path(builtin, what, &text);
}
let colon = text.find(':').unwrap_or(0);
let host = text[..colon].rsplit('@').next().unwrap_or("");
let well_formed = !host.is_empty()
&& !host.starts_with('-')
&& host
.chars()
.all(|c| c.is_ascii_alphanumeric() || "._-".contains(c));
if !well_formed {
return Err(crate::safety::arg_err(format!(
"{builtin}: {what} names host {host:?}, which must be a plain host name"
)));
}
Ok(text)
}
/// The rsync options Record as flags. No Record means `-az`. A key that is not
/// named, or a value of the wrong type, is refused rather than dropped.
fn rsync_flags(builtin: &str, value: Option<&Value>) -> Result<Vec<String>> {
let Some(value) = value else {
return Ok(vec!["-az".to_string()]);
};
let Value::Record(opts) = value else {
return Err(crate::safety::arg_err(format!(
"{builtin}: options must be a Record, got {}",
value.type_name()
)));
};
const FLAGS: [(&str, &str); 5] = [
("archive", "-a"),
("compress", "-z"),
("delete", "--delete"),
("verbose", "-v"),
("progress", "--progress"),
];
for key in opts.keys() {
if key != "exclude" && !FLAGS.iter().any(|(name, _)| key == name) {
return Err(crate::safety::arg_err(format!(
"{builtin}: unknown option {key:?}; the options are archive, compress, delete, verbose, progress and exclude"
)));
}
}
let mut flags = Vec::new();
for (key, flag) in FLAGS {
match opts.get(key) {
None | Some(Value::Bool(false)) => {}
Some(Value::Bool(true)) => flags.push(flag.to_string()),
Some(other) => {
return Err(crate::safety::arg_err(format!(
"{builtin}: option {key} must be a Bool, got {}",
other.type_name()
)))
}
}
}
match opts.get("exclude") {
None => {}
Some(Value::Str(pattern)) => {
let pattern = pkg_plain(builtin, "exclude pattern", pattern)?;
flags.push("--exclude".to_string());
flags.push(pattern);
}
Some(other) => {
return Err(crate::safety::arg_err(format!(
"{builtin}: option exclude must be a String, got {}",
other.type_name()
)))
}
}
Ok(flags)
}
/// `--extra-vars` JSON from a Record of scalars. A key must be an identifier.
/// A nested value is refused rather than turned into its display text.
fn ops_extra_vars(builtin: &str, value: Option<&Value>) -> Result<Option<String>> {
let map = match value {
None => return Ok(None),
Some(Value::Record(map)) => map,
Some(other) => {
return Err(crate::safety::arg_err(format!(
"{builtin}: extra_vars must be a Record, got {}",
other.type_name()
)))
}
};
if map.len() > PKG_MAX_ITEMS {
return Err(crate::safety::arg_err(format!(
"{builtin}: at most {PKG_MAX_ITEMS} extra variables, got {}",
map.len()
)));
}
let mut object = serde_json::Map::new();
for (key, value) in map {
let well_formed = key.len() <= OPS_MAX_NAME
&& key
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
if !well_formed {
return Err(crate::safety::arg_err(format!(
"{builtin}: a variable name is an identifier of letters, digits and '_' (got {key:?})"
)));
}
let json = match value {
Value::Str(s) if !s.contains('\0') => serde_json::Value::String(s.clone()),
Value::Int(n) => serde_json::Value::from(*n),
Value::Float(f) if f.is_finite() => serde_json::json!(*f),
Value::Bool(b) => serde_json::Value::Bool(*b),
other => {
return Err(crate::safety::arg_err(format!(
"{builtin}: the value for {key} must be a String, Int, Float or Bool, got {}",
other.type_name()
)))
}
};
object.insert(key.clone(), json);
}
serde_json::to_string(&serde_json::Value::Object(object))
.map(Some)
.map_err(|e| crate::safety::arg_err(format!("{builtin}: extra_vars: {e}")))
}
/// Whether a forge flag is one this builtin refuses (an editor or browser flag,
/// bare or with a value).
fn ops_forge_flag_refused(flag: &str) -> bool {
OPS_FORGE_REFUSED_FLAGS
.iter()
.any(|refused| flag == *refused || flag.starts_with(&format!("{refused}=")))
}
/// A forge call (gh or glab): one of `verbs` under `group`, then the flags and
/// values that follow it. A read verb is bounded and deadline'd. A write verb
/// changes a remote; the central Network gate asks for approval in agent mode, and it
/// gets the longer deadline.
fn ops_forge(
builtin: &str,
program: &str,
group: &str,
verbs: &[&str],
args: &[Value],
input: &Option<Value>,
) -> Result<Value> {
k8s_no_input(builtin, input)?;
let verb =
k8s_arg(builtin, "subcommand", args, 0, pkg_plain)?.unwrap_or_else(|| "list".to_string());
if !verbs.contains(&verb.as_str()) {
return Err(crate::safety::arg_err(format!(
"{builtin}: subcommand must be one of {}, got {verb:?}",
verbs.join(", ")
)));
}
let extra = pkg_trailing(builtin, args.get(1..).unwrap_or(&[]))?;
if let Some(flag) = extra.iter().find(|a| ops_forge_flag_refused(a.as_str())) {
return Err(crate::safety::arg_err(format!(
"{builtin}: {flag:?} opens an editor or a browser, which this builtin does not run"
)));
}
let writes = OPS_FORGE_WRITE_VERBS.contains(&verb.as_str());
let mut argv: Vec<&str> = vec![group, verb.as_str()];
argv.extend(extra.iter().map(String::as_str));
let run = if writes {
tool_run(
builtin,
program,
None,
&argv,
Keep::Tail(TOOL_KEEP_TAIL),
TOOL_LONG_DEADLINE_SECS,
)?
} else {
tool_run(
builtin,
program,
None,
&argv,
Keep::Head(TOOL_MAX_TEXT),
TOOL_DEADLINE_SECS,
)?
};
pkg_done(builtin, run, vec![("subcmd", Value::Str(verb))])
}
// ===========================================================================
// Terraform (951-958)
// ===========================================================================
/// Why the terraform builtins that reach a provider or backend fail closed under an
/// egress allowlist: the endpoints come from the configuration, not the arguments.
const TF_EGRESS_REASON: &str =
"providers and backends are named by the configuration in the directory, not the arguments";
/// Why the package-manager builtins that fetch from the network fail closed under
/// an egress allowlist: the sources are the system's configured repositories
/// (apt sources, winget sources), not the arguments.
const PKG_EGRESS_REASON: &str =
"the package manager fetches from its configured sources, which the arguments do not name";
/// 951 – terraform_init([dir]): terraform init -input=false -no-color in dir.
/// Downloads the providers the configuration names.
fn bi_terraform_init(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "terraform_init";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 1)?;
crate::safety::check_egress_unknown(B, TF_EGRESS_REASON)?;
let dir = tf_dir(B, k8s_arg(B, "directory", &args, 0, tf_plain)?.as_deref())?;
let run = tf_call(
B,
&dir,
&["init", "-input=false", "-no-color"],
Keep::Tail(TOOL_KEEP_TAIL),
TOOL_LONG_DEADLINE_SECS,
)?;
Ok(Value::Str(tool_text(B, run)?.trim().to_string()))
}
/// 952 – terraform_plan([var_file]): terraform plan in the workspace root.
fn bi_terraform_plan(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "terraform_plan";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 1)?;
crate::safety::check_egress_unknown(B, TF_EGRESS_REASON)?;
let var = tf_var_file(B, &args, 0)?;
let root = tf_dir(B, None)?;
let mut cmd: Vec<&str> = vec!["plan", "-input=false", "-no-color"];
if let Some(flag) = var.as_deref() {
cmd.push(flag);
}
let run = tf_call(
B,
&root,
&cmd,
Keep::Head(TOOL_MAX_TEXT),
TOOL_LONG_DEADLINE_SECS,
)?;
Ok(Value::Str(tool_text(B, run)?.trim().to_string()))
}
/// 953 – terraform_apply([var_file]): terraform apply -auto-approve in the
/// workspace root. Changes infrastructure; agent mode needs approval first
/// (the call is Exec under the central guard).
fn bi_terraform_apply(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "terraform_apply";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 1)?;
crate::safety::check_egress_unknown(B, TF_EGRESS_REASON)?;
let var = tf_var_file(B, &args, 0)?;
let root = tf_dir(B, None)?;
let mut cmd: Vec<&str> = vec!["apply", "-input=false", "-no-color", "-auto-approve"];
if let Some(flag) = var.as_deref() {
cmd.push(flag);
}
let run = tf_call(
B,
&root,
&cmd,
Keep::Tail(TOOL_KEEP_TAIL),
TOOL_LONG_DEADLINE_SECS,
)?;
Ok(Value::Str(tool_text(B, run)?.trim().to_string()))
}
/// 923 – terraform_destroy([dir]): terraform destroy -auto-approve in dir.
/// `-auto-approve` means Terraform never asks, so the approval has to happen
/// here. The directory is resolved and checked against the workspace first, so
/// the approval names the directory that will actually be destroyed.
fn bi_terraform_destroy(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "terraform_destroy";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 1)?;
let dir = tf_dir(B, k8s_arg(B, "directory", &args, 0, tf_plain)?.as_deref())?;
let shown = dir.display().to_string();
crate::safety::guard(crate::safety::GuardCtx {
builtin: B,
effect: crate::safety::Effect::Destructive,
what: B,
targets: vec![shown.clone()],
blast_radius: serde_json::json!({ "dir": shown, "auto_approve": true }),
reversible: false,
fs_paths: false,
})?;
let run = tf_call(
B,
&dir,
&["destroy", "-input=false", "-no-color", "-auto-approve"],
Keep::Tail(TOOL_KEEP_TAIL),
TOOL_LONG_DEADLINE_SECS,
)?;
Ok(Value::Str(tool_text(B, run)?.trim().to_string()))
}
/// 924 – terraform_state([dir]): terraform state list, one Array element per resource.
fn bi_terraform_state(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "terraform_state";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 1)?;
crate::safety::check_egress_unknown(B, TF_EGRESS_REASON)?;
let dir = tf_dir(B, k8s_arg(B, "directory", &args, 0, tf_plain)?.as_deref())?;
let run = tf_call(
B,
&dir,
&["state", "list"],
Keep::Head(TOOL_MAX_TEXT),
TOOL_DEADLINE_SECS,
)?;
let text = tool_text(B, run)?;
let items: Vec<Value> = text
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(|l| Value::Str(l.to_string()))
.collect();
Ok(Value::Array(items))
}
/// 925 – terraform_output([dir]): terraform output -json as a Record.
fn bi_terraform_output(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "terraform_output";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 1)?;
crate::safety::check_egress_unknown(B, TF_EGRESS_REASON)?;
let dir = tf_dir(B, k8s_arg(B, "directory", &args, 0, tf_plain)?.as_deref())?;
let run = tf_call(
B,
&dir,
&["output", "-json"],
Keep::Head(TOOL_MAX_JSON),
TOOL_DEADLINE_SECS,
)?;
tool_json(B, run)
}
/// 926 – terraform_workspace([name]): no name shows the current workspace;
/// a name selects it.
fn bi_terraform_workspace(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "terraform_workspace";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 1)?;
let root = tf_dir(B, None)?;
let run = match k8s_arg(B, "workspace name", &args, 0, tf_workspace_name)? {
Some(name) => {
// `workspace select` checks the name against the backend's workspaces.
// `workspace show` reads .terraform/environment and stays local.
crate::safety::check_egress_unknown(B, TF_EGRESS_REASON)?;
tf_call(
B,
&root,
&["workspace", "select", name.as_str()],
Keep::Head(TOOL_MAX_TEXT),
TOOL_DEADLINE_SECS,
)?
}
None => tf_call(
B,
&root,
&["workspace", "show"],
Keep::Head(TOOL_MAX_TEXT),
TOOL_DEADLINE_SECS,
)?,
};
Ok(Value::Str(tool_text(B, run)?.trim().to_string()))
}
/// 927 – terraform_validate([dir]): terraform validate -json as a Record. An
/// invalid configuration is an answer: its diagnostics come back with
/// `valid: false`, not as a failure.
fn bi_terraform_validate(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "terraform_validate";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 1)?;
let dir = tf_dir(B, k8s_arg(B, "directory", &args, 0, tf_plain)?.as_deref())?;
let run = tf_call(
B,
&dir,
&["validate", "-json"],
Keep::Head(TOOL_MAX_JSON),
TOOL_DEADLINE_SECS,
)?;
if !run.success {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&run.stdout) {
if json.get("valid").is_some() {
return Ok(json_to_value(json));
}
}
}
tool_json(B, run)
}
// ===========================================================================
// Ansible (928-931)
// ===========================================================================
/// 928 – ansible_playbook(playbook, [inventory], [extra_vars]): ansible-playbook
/// on a playbook inside the workspace, returning the tail of its output. The
/// playbook and inventory must exist inside the workspace, so an inline host
/// list is refused. extra_vars travel on the command line, where other local
/// users can read them in a process listing: keep secrets in a vault file.
/// Runs with a 15 minute deadline; a nonzero exit is E_TOOL_FAILED.
fn bi_ansible_playbook(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "ansible_playbook";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 3)?;
crate::safety::check_egress_unknown(
B,
"the hosts it connects to are named by the inventory and playbook, not the arguments",
)?;
let playbook = k8s_need(B, "a playbook path", &args, 0, ops_playbook)?;
let mut argv: Vec<String> = vec![playbook];
if let Some(inventory) = k8s_arg(B, "inventory", &args, 1, ops_inventory)? {
argv.push("-i".to_string());
argv.push(inventory);
}
if let Some(vars) = ops_extra_vars(B, args.get(2))? {
argv.push("--extra-vars".to_string());
argv.push(vars);
}
let refs: Vec<&str> = argv.iter().map(String::as_str).collect();
let run = tool_run(
B,
"ansible-playbook",
None,
&refs,
Keep::Tail(TOOL_KEEP_TAIL),
TOOL_LONG_DEADLINE_SECS,
)?;
Ok(Value::Str(tool_text(B, run)?.trim().to_string()))
}
/// 929 – ansible_inventory(inventory): ansible-inventory --list for an inventory
/// inside the workspace, as JSON.
fn bi_ansible_inventory(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "ansible_inventory";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 1)?;
let inventory = k8s_need(B, "an inventory path", &args, 0, ops_inventory)?;
let run = tool_run(
B,
"ansible-inventory",
None,
&["--list", "-i", inventory.as_str()],
Keep::Head(TOOL_MAX_JSON),
TOOL_DEADLINE_SECS,
)?;
tool_json(B, run)
}
/// 930 – ansible_galaxy(role): ansible-galaxy install for a role name, a
/// registry spec or a path inside the workspace. A registry or URL spec is
/// fetched from the network. A `file:` or `link:` spec is refused.
fn bi_ansible_galaxy(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "ansible_galaxy";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 1)?;
crate::safety::check_egress_unknown(
B,
"a registry role is fetched from the galaxy server ansible is configured with, not the argument",
)?;
let role = k8s_need(B, "a role name", &args, 0, pkg_spec)?;
let run = tool_run(
B,
"ansible-galaxy",
None,
&["install", role.as_str()],
Keep::Tail(TOOL_KEEP_TAIL),
TOOL_LONG_DEADLINE_SECS,
)?;
Ok(Value::Str(tool_text(B, run)?.trim().to_string()))
}
/// 931 – ansible_vault(action, file): ansible-vault view, encrypt or decrypt on
/// a file inside the workspace. `view` returns the decrypted text as the
/// answer, so a secret reaches the caller; `decrypt` leaves the file in plain
/// text. No vault password is accepted or placed on the command line. Where no
/// password source is configured, ansible-vault prompts on the terminal, and
/// the deadline bounds that wait.
fn bi_ansible_vault(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "ansible_vault";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 2)?;
let action = k8s_need(
B,
"an action (view, encrypt or decrypt)",
&args,
0,
ops_vault_action,
)?;
let file = k8s_need(B, "a vault file path", &args, 1, ops_vault_file)?;
let run = tool_run(
B,
"ansible-vault",
None,
&[action.as_str(), file.as_str()],
Keep::Head(TOOL_MAX_TEXT),
TOOL_DEADLINE_SECS,
)?;
Ok(Value::Str(tool_text(B, run)?))
}
// ============================================================================
// Core OS Builtins (950-979)
// File ops, block devices, kernel, system monitoring, diagnostics
// ============================================================================
// [removed duplicate] use std::collections::BTreeMap;
// [removed duplicate] use std::process::Command;
// [removed duplicate] use anyhow::{anyhow, Result};
// Assumes these are defined elsewhere in the crate:
// pub enum Value { Str(String), Int(i64), Float(f64), Bool(bool), Array(Vec<Value>), Record(BTreeMap<String,Value>), Null }
// fn json_to_value(json: serde_json::Value) -> Value;
/// cd(path) - Change the process working directory.
///
/// The catalog has advertised this with a full signature and an example --
/// `cd(path: String) -> String`, category FileSystem -- and there was no
/// implementation anywhere. `pwd()` worked, so the shell could report its
/// directory and not change it, and `shell.cd(...)` dispatched to a builtin
/// that did not exist.
///
/// Guarded with `fs_paths: true` deliberately. The working directory decides
/// what every later relative path means, so an unguarded `cd` is a way out of
/// the workspace jail: `cd("..")` then `write("secrets")` would otherwise land
/// outside a root the agent was confined to.
///
/// `WriteLocal` rather than `Pure`: it modifies process state, and `pwd()`
/// returns a different answer afterwards. Classifying it `Pure` would tell an
/// agent the call is referentially transparent and safe to reorder, which is
/// the one thing it certainly is not.
fn bi_cd(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("cd", &input)?;
k8s_max_args("cd", &args, 1)?;
let raw = match args.first() {
Some(Value::Str(s)) if !s.is_empty() => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"cd",
"path: a non-empty String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let path = crate::safety::resolve_path_str(&raw);
crate::safety::guard(crate::safety::GuardCtx {
builtin: "cd",
effect: crate::safety::Effect::WriteLocal,
what: "change directory",
targets: vec![path.clone()],
blast_radius: serde_json::json!({ "cwd": true }),
reversible: true,
fs_paths: true,
})?;
std::env::set_current_dir(&path).map_err(|e| crate::safety::fs_error("cd", &path, &e))?;
let now = std::env::current_dir()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or(path);
Ok(Value::Str(now))
}
/// An optional Bool flag argument. A value of any other type used to read as
/// false, so `rm(p, "yes")` was a plain `rm` that said nothing about it.
fn fs_flag(builtin: &str, what: &str, value: Option<&Value>) -> Result<bool> {
match value {
None => Ok(false),
Some(Value::Bool(b)) => Ok(*b),
Some(other) => Err(crate::safety::bad_arg(
builtin,
&format!("{what}: Bool"),
other.type_name(),
)),
}
}
/// 950: bi_rm - Remove one file. Args: path (string), optional force bool.
pub fn bi_rm(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("rm", &input)?;
k8s_max_args("rm", &args, 2)?;
let path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"rm",
"path: String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let force = fs_flag("rm", "force", args.get(1))?;
// Jailed: relative paths are workspace-relative; resolve before the jail check
// so the guard validates the path that is actually deleted.
let path = crate::safety::resolve_path_str(&path);
crate::safety::guard(crate::safety::GuardCtx {
builtin: "rm",
effect: crate::safety::Effect::Destructive,
what: "delete",
targets: vec![path.clone()],
blast_radius: serde_json::json!({ "files": 1 }),
reversible: false,
fs_paths: true,
})?;
crate::tx::snapshot(&path); // record pre-delete state if a transaction is active
// `force` excuses a file that is already gone, as `rm -f` does, and nothing
// else. It used to turn every failure -- a directory, a locked file, a
// permission refusal -- into a success string, so a delete that did not
// happen read as one that had.
match std::fs::remove_file(&path) {
Ok(()) => Ok(Value::Str(format!("removed: {}", path))),
Err(e) if force && e.kind() == std::io::ErrorKind::NotFound => {
Ok(Value::Str(format!("already absent: {}", path)))
}
Err(e) => Err(crate::safety::fs_error("rm", &path, &e)),
}
}
/// 951: bi_rmdir - Remove directory. Args: path, optional recursive bool.
pub fn bi_rmdir(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("rmdir", &input)?;
k8s_max_args("rmdir", &args, 2)?;
let path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"rmdir",
"path: String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let recursive = fs_flag("rmdir", "recursive", args.get(1))?;
// Jailed: relative paths are workspace-relative (resolve before the jail check).
let path = crate::safety::resolve_path_str(&path);
crate::safety::guard(crate::safety::GuardCtx {
builtin: "rmdir",
effect: crate::safety::Effect::Destructive,
what: "delete",
targets: vec![path.clone()],
blast_radius: serde_json::json!({ "recursive": recursive }),
reversible: false,
fs_paths: true,
})?;
crate::tx::snapshot(&path); // back up the directory tree if a transaction is active
// `remove_dir_all` removes a symbolic link rather than following it, so a
// link inside the workspace to a directory outside it is not a way out.
if recursive {
std::fs::remove_dir_all(&path).map_err(|e| crate::safety::fs_error("rmdir", &path, &e))?;
} else {
std::fs::remove_dir(&path).map_err(|e| crate::safety::fs_error("rmdir", &path, &e))?;
}
Ok(Value::Str(format!("removed directory: {}", path)))
}
/// 952: bi_touch - Create empty file or update timestamp. Args: path.
fn bi_touch(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let path = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => return Err(crate::safety::arg_err("touch: expected path string")),
};
// Opening with create+write and writing nothing does not change an
// existing file's modification time, so this did not touch anything
// that already existed; errors were uncoded. It also skipped the
// workspace jail the other writers go through.
let path = guard_local_write("touch", &path)?;
// An existing file gets only its timestamp changed, so its metadata is what
// is recorded; a file this call creates is recorded as absent so rollback
// removes it.
if std::path::Path::new(&path).exists() {
crate::tx::snapshot_metadata(&path);
} else {
crate::tx::snapshot(&path);
}
// A directory is touched like a file is: its time changes. Opening it to
// append failed with "is a directory".
let file = if std::path::Path::new(&path).is_dir() {
crate::tx::open_for_times(std::path::Path::new(&path))
} else {
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
}
.map_err(|e| crate::safety::fs_error("touch", &path, &e))?;
file.set_modified(std::time::SystemTime::now())
.map_err(|e| crate::safety::fs_error("touch", &path, &e))?;
Ok(Value::Str(path))
}
/// 953: bi_file_type - Detect file MIME type (cross-platform, no shell-out).
fn bi_file_type(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let path = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => return Err(crate::safety::arg_err("file_type: expected path string")),
};
let ext = std::path::Path::new(&path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let mime = match ext.as_str() {
"txt" | "text" | "log" => "text/plain",
"html" | "htm" => "text/html",
"css" => "text/css",
"js" | "mjs" | "cjs" => "text/javascript",
"json" => "application/json",
"xml" => "application/xml",
"csv" => "text/csv",
"md" | "markdown" => "text/markdown",
"yaml" | "yml" => "application/yaml",
"toml" => "application/toml",
"rs" => "text/x-rust",
"py" => "text/x-python",
"rb" => "text/x-ruby",
"java" => "text/x-java",
"c" | "h" => "text/x-c",
"cpp" | "cxx" | "cc" | "hpp" => "text/x-c++",
"go" => "text/x-go",
"ts" | "tsx" => "text/typescript",
"sh" | "bash" | "zsh" => "text/x-shellscript",
"ps1" => "text/x-powershell",
"bat" | "cmd" => "text/x-batch",
"sql" => "text/x-sql",
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"bmp" => "image/bmp",
"svg" => "image/svg+xml",
"webp" => "image/webp",
"mp3" => "audio/mpeg",
"wav" => "audio/wav",
"mp4" | "m4v" => "video/mp4",
"webm" => "video/webm",
"avi" => "video/x-msvideo",
"mkv" => "video/x-matroska",
"pdf" => "application/pdf",
"zip" => "application/zip",
"gz" | "gzip" => "application/gzip",
"tar" => "application/x-tar",
"exe" => "application/x-executable",
"dll" | "so" | "dylib" => "application/x-sharedlib",
"wasm" => "application/wasm",
_ => "application/octet-stream",
};
Ok(Value::Str(mime.to_string()))
}
/// 957: bi_lsblk - List block devices (cross-platform via sysinfo::Disks).
fn bi_lsblk(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let _ = args;
let disks = Disks::new_with_refreshed_list();
let entries: Vec<Value> = disks
.iter()
.map(|d| {
let mut rec = BTreeMap::new();
rec.insert(
"name".to_string(),
Value::Str(d.name().to_string_lossy().to_string()),
);
rec.insert(
"mount_point".to_string(),
Value::Str(d.mount_point().to_string_lossy().to_string()),
);
rec.insert(
"total_space".to_string(),
Value::Int(d.total_space() as i64),
);
rec.insert(
"available_space".to_string(),
Value::Int(d.available_space() as i64),
);
rec.insert(
"file_system".to_string(),
Value::Str(String::from_utf8_lossy(d.file_system().as_encoded_bytes()).to_string()),
);
rec.insert("is_removable".to_string(), Value::Bool(d.is_removable()));
Value::Record(rec)
})
.collect();
Ok(Value::Array(entries))
}
#[path = "linux_diagnostics.rs"]
mod linux_diagnostics;
#[path = "monitor_parse.rs"]
mod monitor_parse;
#[path = "stdin_lines.rs"]
mod stdin_lines;
#[cfg(feature = "native")]
#[path = "image_match.rs"]
mod image_match;
#[path = "pkg_query.rs"]
mod pkg_query;
#[path = "macos_plist.rs"]
mod macos_plist;
/// Windows: the drive a `C:`, `C:\` or `c:/` argument names, as its upper-case
/// letter. Anything else is refused: blkid on Windows takes a drive, not a path.
#[cfg(windows)]
fn blkid_drive(device: &str) -> Result<char> {
let b = device.as_bytes();
let plain = (b.len() == 2 || b.len() == 3)
&& b[0].is_ascii_alphabetic()
&& b[1] == b':'
&& (b.len() == 2 || b[2] == b'\\' || b[2] == b'/');
if plain {
Ok(char::from(b[0].to_ascii_uppercase()))
} else if device.starts_with("/dev/") {
// A well-formed Unix device names nothing here, as a missing device
// does on Linux, so a script written for both gets the same code. It
// is checked as on Linux first: `/dev/../x` is still a bad argument.
linux_diagnostics::device_path("blkid", device)?;
Err(crate::safety::not_found(
"blkid",
"block device",
&format!("{device} (Windows names drives, such as C:)"),
))
} else {
Err(crate::safety::bad_arg(
"blkid",
"a drive such as C: or C:\\",
device,
))
}
}
/// Windows: one drive's volume and partition, from Get-Volume and Get-Partition.
/// The volume's UniqueId is uuid and the partition's Guid is partuuid, the way
/// blkid reports them on Linux; type is the file system lower-cased. A field the
/// drive does not report is left out, not made up.
#[cfg(windows)]
fn blkid_windows(device: &str) -> Result<Value> {
const NAME: &str = "blkid";
let letter = blkid_drive(device)?.to_string();
// A drive with no volume prints nothing, which the parse below reads as
// absent. Get-Volume exits 1 for it even under SilentlyContinue, so only
// ObjectNotFound is taken as absence; any other error still fails.
let script = crate::ps_script!(
"try {{ $v = Get-Volume -DriveLetter {} -ErrorAction Stop }} catch {{ if ($_.CategoryInfo.Category -eq 'ObjectNotFound') {{ exit 0 }} else {{ throw }} }}; $p = Get-Partition -DriveLetter {} -ErrorAction SilentlyContinue | Select-Object -First 1; [pscustomobject]@{{ UniqueId=$v.UniqueId; FileSystemType=$v.FileSystemType; FileSystemLabel=$v.FileSystemLabel; PartitionGuid=$p.Guid }} | ConvertTo-Json",
crate::safety::ps_quote(&letter),
crate::safety::ps_quote(&letter)
);
let out = net_run(
NAME,
"powershell",
&["-NoProfile", "-NonInteractive", "-Command", script.as_str()],
NET_TOOL_SECS,
)?;
net_checked(NAME, "powershell", &out)?;
let Some(item) = net_ps_items(NAME, &out.stdout)?.into_iter().next() else {
return Err(crate::safety::not_found(
NAME,
"identifiable device",
device,
));
};
let Some(o) = item.as_object() else {
return Err(net_unparsed(NAME, "powershell"));
};
let text = |key: &str| {
o.get(key)
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
};
let mut rec = BTreeMap::new();
rec.insert("devname".to_string(), Value::Str(format!("{letter}:")));
if let Some(s) = text("UniqueId") {
rec.insert("uuid".to_string(), Value::Str(s));
}
if let Some(s) = text("PartitionGuid") {
rec.insert("partuuid".to_string(), Value::Str(s));
}
if let Some(s) = text("FileSystemType") {
rec.insert("type".to_string(), Value::Str(s.to_lowercase()));
}
if let Some(s) = text("FileSystemLabel") {
rec.insert("label".to_string(), Value::Str(s));
}
// Only the drive name is left: nothing identifies a volume, as on Linux.
if rec.len() == 1 {
return Err(crate::safety::not_found(
NAME,
"identifiable device",
device,
));
}
Ok(Value::Record(rec))
}
/// macOS: one device's attributes from `diskutil info -plist`, which changes
/// nothing. devname is DeviceNode, uuid is VolumeUUID (DiskUUID when the device
/// has no volume), type is FilesystemType lower-cased, and label is VolumeName.
/// A key the device does not report is left out, as on Linux and Windows. No
/// partuuid is given: DiskUUID is not the GPT partition GUID blkid reports.
#[cfg(target_os = "macos")]
fn blkid_macos(device: &str) -> Result<Value> {
const NAME: &str = "blkid";
let limit = std::time::Duration::from_secs(20);
let argv = ["info".to_string(), "-plist".to_string(), device.to_string()];
let run = linux_diagnostics::run_bounded(NAME, "diskutil", "diskutil", &argv, limit)?;
linux_diagnostics::require_success(NAME, "diskutil", &run, limit)?;
let plist = macos_plist::parse_top_dict(&run.stdout)
.map_err(|e| crate::safety::tool_failed(NAME, "diskutil info -plist", Some(0), &e))?;
let text = |key: &str| match plist.get(key) {
Some(macos_plist::PlistValue::Str(s)) if !s.trim().is_empty() => Some(s.trim().to_string()),
_ => None,
};
let mut rec = BTreeMap::new();
if let Some(s) = text("DeviceNode") {
rec.insert("devname".to_string(), Value::Str(s));
}
if let Some(s) = text("VolumeUUID").or_else(|| text("DiskUUID")) {
rec.insert("uuid".to_string(), Value::Str(s));
}
if let Some(s) = text("FilesystemType") {
rec.insert("type".to_string(), Value::Str(s.to_lowercase()));
}
if let Some(s) = text("VolumeName") {
rec.insert("label".to_string(), Value::Str(s));
}
if rec.is_empty() {
return Err(crate::safety::not_found(
NAME,
"identifiable device",
device,
));
}
Ok(Value::Record(rec))
}
/// 958: bi_blkid - Block device attributes (Linux and macOS; Windows reads a drive).
fn bi_blkid(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
linux_diagnostics::no_pipeline_input("blkid", &input)?;
let device = match args.as_slice() {
[Value::Str(s)] => s.clone(),
[other] => {
return Err(crate::safety::bad_arg(
"blkid",
"a device path String such as /dev/sda1",
other.type_name(),
))
}
_ => {
return Err(crate::safety::arg_err(
"blkid: expected exactly one device path",
))
}
};
#[cfg(not(windows))]
linux_diagnostics::device_path("blkid", &device)?;
#[cfg(target_os = "linux")]
{
let limit = std::time::Duration::from_secs(20);
let run = linux_diagnostics::run_bounded(
"blkid",
"blkid",
"blkid",
&["-o".to_string(), "export".to_string(), device.clone()],
limit,
)?;
// blkid exits 2 for a device it cannot identify.
if !run.timed_out && !run.truncated && run.code == Some(2) {
return Err(crate::safety::not_found(
"blkid",
"identifiable device",
&device,
));
}
linux_diagnostics::require_success("blkid", "blkid", &run, limit)?;
let mut rec = BTreeMap::new();
for line in run.stdout.lines() {
if let Some((k, v)) = line.split_once('=') {
let k = k.trim().to_lowercase();
if !k.is_empty() {
rec.insert(k, Value::Str(v.to_string()));
}
}
}
if rec.is_empty() {
return Err(crate::safety::not_found(
"blkid",
"identifiable device",
&device,
));
}
Ok(Value::Record(rec))
}
#[cfg(windows)]
{
blkid_windows(&device)
}
#[cfg(target_os = "macos")]
{
blkid_macos(&device)
}
#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
{
Err(crate::safety::unimplemented(
"blkid",
"blkid is Linux, macOS and Windows only; NOTHING WAS DONE on this operating system",
"lsblk() lists block devices on every OS",
))
}
}
/// Longest host name a network tool accepts.
const NET_HOST_MAX: usize = 253;
/// Most words a command line may hold once split into argv.
const NET_ARGV_MAX: usize = 256;
/// Stderr kept from a network or debug tool, for a failure message or a report.
const NET_ERR_MAX: usize = 4096;
/// Most items one xargs_exec call may run.
const XARGS_MAX_ITEMS: usize = 256;
/// Output kept from one run of xargs_exec, on each stream.
const XARGS_ITEM_OUT: usize = 16 * 1024;
/// Wall-clock limit on one xargs_exec call, all of its items together.
const XARGS_TOTAL_SECS: u64 = 600;
/// Redirects http_get follows, each one checked before it is taken.
const HTTP_MAX_REDIRECTS: usize = 5;
/// Largest response body http_get reads.
const HTTP_BODY_MAX: u64 = 16 * 1024 * 1024;
/// Largest file wget_download writes.
const WGET_FILE_MAX: u64 = 1024 * 1024 * 1024;
/// Largest logrotate configuration that is read.
const LOGROTATE_CONFIG_MAX: u64 = 1024 * 1024;
/// Most log files multitail_open opens at once.
const MULTITAIL_MAX_FILES: usize = 16;
/// The port iperf3 listens on when none is given.
const IPERF3_PORT: i64 = 5201;
/// The nmap flags nmap_scan passes through. Every other nmap option is refused:
/// --script runs scripts, -oN/-oG/--log-file write files, and the rest change
/// what nmap reads or runs.
const NMAP_FLAGS: &[&str] = &[
"-sT", "-sS", "-sU", "-sV", "-Pn", "-n", "-T0", "-T1", "-T2", "-T3", "-T4", "-T5",
];
/// logrotate directives that run a program or send mail. A configuration that
/// names one is refused, because the command it would run is out of sight here.
const LOGROTATE_REFUSED: &[&str] = &[
"prerotate",
"postrotate",
"firstaction",
"lastaction",
"preremove",
"mail",
"mailfirst",
"maillast",
];
/// A host name or IP literal given to a network tool as an argument: letters,
/// digits, '.', '-' and ':', at most NET_HOST_MAX bytes. A leading '-' is
/// refused as an option, and every other character is refused by the rule.
fn net_target_host(builtin: &str, what: &str, value: &str) -> Result<String> {
crate::safety::reject_option_like(builtin, &[value.to_string()])?;
let well_formed = !value.is_empty()
&& value.len() <= NET_HOST_MAX
&& value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b':'));
if well_formed {
Ok(value.to_string())
} else {
Err(crate::safety::arg_err(format!(
"{builtin}: {what} must be a host name or IP literal of letters, digits, '.', '-' and ':' (at most {NET_HOST_MAX} bytes), got {value:?}"
)))
}
}
/// A TCP or UDP port: an Int in 1..=65535.
fn net_port(builtin: &str, n: i64) -> Result<i64> {
if (1..=65535).contains(&n) {
Ok(n)
} else {
Err(crate::safety::arg_err(format!(
"{builtin}: port must be 1..=65535, got {n}"
)))
}
}
/// A systemd unit name for journalctl: letters, digits, and '-', '_', '.', '@'.
fn journal_unit(builtin: &str, what: &str, value: &str) -> Result<String> {
crate::safety::reject_option_like(builtin, &[value.to_string()])?;
let well_formed = !value.is_empty()
&& value.len() <= 256
&& value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'@'));
if well_formed {
Ok(value.to_string())
} else {
Err(crate::safety::arg_err(format!(
"{builtin}: {what} must be a systemd unit name (letters, digits and - _ . @), got {value:?}"
)))
}
}
/// A command line split into argv on whitespace. There is no quoting and no
/// shell: each word is one argument, and shell metacharacters are inert. The
/// program word may not begin with '-'.
fn argv_words(builtin: &str, command: &str) -> Result<Vec<String>> {
let words: Vec<String> = command.split_whitespace().map(str::to_string).collect();
let Some(program) = words.first() else {
return Err(crate::safety::arg_err(format!(
"{builtin}: the command is empty"
)));
};
crate::safety::reject_option_like(builtin, std::slice::from_ref(program))?;
if words.len() > NET_ARGV_MAX {
return Err(crate::safety::budget_exceeded(
builtin,
format!(
"the command has {} words; the limit is {NET_ARGV_MAX}",
words.len()
),
"pass a shorter command line",
));
}
Ok(words)
}
/// The exit code of a finished run; a run ended by a signal has none, and is -1.
fn exit_code_of(run: &ToolRun) -> i64 {
run.code.map_or(-1, i64::from)
}
/// A network request that failed before any answer came back: a coded E_IO
/// that says what was attempted.
fn net_io_error(builtin: &str, what: &str, detail: impl std::fmt::Display) -> anyhow::Error {
anyhow::Error::new(crate::safety::SafetyError {
code: crate::safety::ErrorCode::Io,
message: format!("{builtin}: {what}: {detail}"),
builtin: builtin.to_string(),
hint: "no answer came back; check the address and the network, then retry".to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: format!("{what} to complete"),
got: "no answer".to_string(),
})
}
/// Whether netcat's failure text says the port was closed or did not answer: a
/// determined "not open", as opposed to netcat itself failing.
fn nc_refusal(text: &str) -> bool {
let lower = text.to_ascii_lowercase();
lower.contains("refused") || lower.contains("timed out") || lower.contains("timeout")
}
/// One socat address for socat_relay. Only an outbound TCP or UDP connection to
/// a host name or IPv4 literal and a port is allowed, with no options. EXEC,
/// SYSTEM and SHELL run programs, OPEN and CREATE write files, and every LISTEN
/// form opens a listener, so each of those is refused by policy.
fn socat_address(builtin: &str, value: &str) -> Result<String> {
crate::safety::reject_option_like(builtin, &[value.to_string()])?;
let Some((kind, rest)) = value.split_once(':') else {
return Err(crate::safety::bad_arg(
builtin,
"an address such as TCP:host:port",
value,
));
};
let kind_up = kind.to_ascii_uppercase();
if !matches!(kind_up.as_str(), "TCP" | "TCP4" | "UDP" | "UDP4") {
return Err(crate::safety::policy_deny(
builtin,
format!("{builtin}: socat address type `{kind}` is refused"),
"only outbound TCP, TCP4, UDP and UDP4 connections to a host and port are allowed; EXEC, SYSTEM and SHELL run programs, OPEN and CREATE write files, and LISTEN forms open listeners",
));
}
let Some((host, port)) = rest.rsplit_once(':') else {
return Err(crate::safety::bad_arg(
builtin,
"TCP:host:port or UDP:host:port",
value,
));
};
let host = net_target_host(builtin, "the socat address host", host)?;
if host.contains(':') {
return Err(crate::safety::bad_arg(
builtin,
"a host name or IPv4 literal without ':' in the address",
value,
));
}
let port = if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) {
port.parse::<i64>().ok()
} else {
None
};
let Some(port) = port else {
return Err(crate::safety::bad_arg(
builtin,
"a port number with no options after it",
value,
));
};
let port = net_port(builtin, port)?;
Ok(format!("{kind_up}:{host}:{port}"))
}
/// The ports nmap_scan may scan: digits, ',' and '-', with every number in
/// 1..=65535 and every range low <= high.
fn nmap_ports(builtin: &str, what: &str, value: &str) -> Result<String> {
let ok = !value.is_empty()
&& value.len() <= 1024
&& value
.bytes()
.all(|b| b.is_ascii_digit() || matches!(b, b',' | b'-'))
&& value.split(',').all(|item| {
let mut bounds = item.splitn(2, '-');
let lo = bounds.next().and_then(|s| s.parse::<u32>().ok());
let hi = match bounds.next() {
Some(s) => s.parse::<u32>().ok(),
None => lo,
};
matches!((lo, hi), (Some(l), Some(h)) if (1..=65535).contains(&l) && (1..=65535).contains(&h) && l <= h)
});
if ok {
Ok(value.to_string())
} else {
Err(crate::safety::arg_err(format!(
"{builtin}: {what} must be ports or ranges such as \"22,80,1000-1100\", each 1..=65535 (got {value:?})"
)))
}
}
/// The nmap flags nmap_scan accepts, whitespace-separated. Anything not in
/// NMAP_FLAGS is refused by policy.
fn nmap_flags(builtin: &str, what: &str, value: &str) -> Result<String> {
for word in value.split_whitespace() {
if !NMAP_FLAGS.contains(&word) {
return Err(crate::safety::policy_deny(
builtin,
format!("{builtin}: {what} `{word}` is not an allowed nmap flag"),
format!(
"allowed flags: {}; scripts (--script) and output files (-oN, -oG, --log-file) are refused",
NMAP_FLAGS.join(" ")
),
));
}
}
Ok(value.to_string())
}
/// The items xargs_exec runs: an Array of scalars, or a String read as lines
/// with empty lines skipped. Each item is one argument, so an item that begins
/// with '-' is refused as an option.
fn xargs_items(builtin: &str, value: &Value) -> Result<Vec<String>> {
let items: Vec<String> = match value {
Value::Array(arr) => arr
.iter()
.map(|v| match v {
Value::Str(s) => Ok(s.clone()),
Value::Int(n) => Ok(n.to_string()),
Value::Float(f) => Ok(f.to_string()),
Value::Bool(b) => Ok(b.to_string()),
other => Err(crate::safety::bad_arg(
builtin,
"scalar items (String, Int, Float or Bool)",
other.type_name(),
)),
})
.collect::<Result<Vec<String>>>()?,
Value::Str(s) => s
.lines()
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect(),
other => {
return Err(crate::safety::bad_arg(
builtin,
"an Array or a String of items",
other.type_name(),
))
}
};
if items.len() > XARGS_MAX_ITEMS {
return Err(crate::safety::budget_exceeded(
builtin,
format!("{} items; the limit is {XARGS_MAX_ITEMS}", items.len()),
"run the items in more than one call",
));
}
for item in &items {
if item.len() > 4096 || item.contains('\0') {
return Err(crate::safety::arg_err(format!(
"{builtin}: each item is at most 4096 bytes and has no NUL"
)));
}
crate::safety::reject_option_like(builtin, std::slice::from_ref(item))?;
}
Ok(items)
}
/// One `strace -c` summary row, read from its default columns: percent,
/// seconds, usecs/call, calls, errors, syscall. A row with any other shape is
/// counted and returned as the second value, never dropped.
fn strace_summary(text: &str) -> (Vec<Value>, usize) {
let mut rows = Vec::new();
let mut unparsed = 0usize;
let mut in_table = false;
let mut after_rule = false;
for line in text.lines() {
let t = line.trim();
if t.starts_with("% time") {
in_table = true;
continue;
}
if !in_table {
continue;
}
if t.starts_with("------") {
if after_rule {
break;
}
after_rule = true;
continue;
}
if t.is_empty() {
continue;
}
let cols: Vec<&str> = t.split_whitespace().collect();
if cols.last() == Some(&"total") {
continue;
}
let parsed = match cols.as_slice() {
[pct, secs, _usecs, calls, errors, name] => {
match (
pct.parse::<f64>(),
secs.parse::<f64>(),
calls.parse::<i64>(),
errors.parse::<i64>(),
) {
(Ok(pct), Ok(secs), Ok(calls), Ok(errors)) => {
Some((pct, secs, calls, errors, *name))
}
_ => None,
}
}
_ => None,
};
match parsed {
Some((pct, secs, calls, errors, name)) => {
let mut rec = BTreeMap::new();
rec.insert("percent".to_string(), Value::Float(pct));
rec.insert("seconds".to_string(), Value::Float(secs));
rec.insert("calls".to_string(), Value::Int(calls));
rec.insert("errors".to_string(), Value::Int(errors));
rec.insert("name".to_string(), Value::Str(name.to_string()));
rows.push(Value::Record(rec));
}
None => unparsed += 1,
}
}
(rows, unparsed)
}
/// One line of `nm --demangle` output: `address type name`, or `type name` for
/// an undefined symbol, whose address is null. A demangled name may contain
/// spaces, so only the first fields are split off.
fn nm_record(line: &str) -> Value {
let mut rec = BTreeMap::new();
let t = line.trim();
let (address, rest) = match t.split_once(' ') {
Some((first, rest)) if first.len() >= 8 && first.bytes().all(|b| b.is_ascii_hexdigit()) => {
(Value::Str(first.to_string()), rest.trim_start())
}
_ => (Value::Null, t),
};
match rest.split_once(' ') {
Some((ty, name)) if ty.len() == 1 && !name.trim().is_empty() => {
rec.insert("address".to_string(), address);
rec.insert("type".to_string(), Value::Str(ty.to_string()));
rec.insert("name".to_string(), Value::Str(name.trim().to_string()));
}
_ => {
rec.insert("line".to_string(), Value::Str(line.to_string()));
}
}
Value::Record(rec)
}
/// Directives in a logrotate configuration are checked before the tool runs.
/// A path that is absolute, starts with '~', or contains '..' could reach
/// outside the workspace, and a directive in LOGROTATE_REFUSED runs a program.
fn logrotate_config_check(builtin: &str, text: &str) -> Result<()> {
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
for token in line.split_whitespace() {
let word = token.trim_matches(|c| c == '"' || c == '\'');
if LOGROTATE_REFUSED.contains(&word) {
return Err(crate::safety::policy_deny(
builtin,
format!("{builtin}: the configuration uses `{word}`, which runs a program or sends mail"),
"remove that directive; this call runs logrotate without a shell and cannot see the commands it would run",
));
}
let absolute = word.starts_with('/')
|| word.starts_with('\\')
|| word.starts_with('~')
|| word.as_bytes().get(1) == Some(&b':');
if absolute || word.contains("..") {
return Err(crate::safety::policy_deny(
builtin,
format!("{builtin}: the configuration names `{word}`, which may lie outside the workspace"),
"use paths relative to the configuration file, with no '..', so every file it touches is inside the workspace",
));
}
}
}
Ok(())
}
/// journalctl — the most recent entries of the systemd journal, optionally of one unit.
/// Usage: journalctl(), journalctl(count) or journalctl(count, unit). count is
/// 1..=1000 and defaults to 50. Returns an Array of entries, each a Record of
/// the journal's JSON fields. A line that is not JSON is E_TOOL_FAILED, never
/// skipped, and output past 16 MiB is E_BUDGET_EXCEEDED. Elsewhere the call is
/// E_UNIMPLEMENTED after its arguments are checked.
///
/// On Windows the entries are the newest `count` events of the System log, as
/// Records {time, provider, level, id, message}. `unit` is then an event
/// provider's name, such as "Service Control Manager". Nothing else maps: the
/// Linux call takes no priority, so none is accepted here.
///
/// On macOS the entries are the newest `count` entries of the unified log from
/// the last hour, as Records of the `log show --style ndjson` fields, and `unit`
/// is a process name. See journal_macos.
fn bi_journalctl(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("journalctl", &input)?;
k8s_max_args("journalctl", &args, 2)?;
let count = match args.first() {
None => 50,
Some(Value::Int(n)) if (1..=1000).contains(n) => *n,
Some(other) => {
return Err(crate::safety::bad_arg(
"journalctl",
"count: Int 1..=1000",
other.type_name(),
))
}
};
#[cfg(windows)]
let unit = k8s_arg("journalctl", "unit", &args, 1, journal_provider)?;
#[cfg(not(windows))]
let unit = k8s_arg("journalctl", "unit", &args, 1, journal_unit)?;
#[cfg(windows)]
{
journal_windows(count, unit)
}
#[cfg(target_os = "linux")]
{
let count_s = count.to_string();
let mut argv: Vec<&str> = vec!["-n", count_s.as_str(), "-o", "json", "--no-pager"];
if let Some(u) = unit.as_deref() {
argv.extend(["-u", u]);
}
let run = tool_run(
"journalctl",
"journalctl",
None,
&argv,
Keep::Head(TOOL_MAX_JSON),
TOOL_DEADLINE_SECS,
)?;
let text = tool_text("journalctl", run)?;
let mut entries = Vec::new();
for line in text.lines().filter(|l| !l.trim().is_empty()) {
let json = serde_json::from_str::<serde_json::Value>(line).map_err(|e| {
crate::safety::tool_failed(
"journalctl",
"journalctl -o json",
Some(0),
&format!("printed a line that is not JSON ({e})"),
)
})?;
entries.push(json_to_value(json));
}
Ok(Value::Array(entries))
}
#[cfg(target_os = "macos")]
{
journal_macos(count, unit)
}
#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
{
let _ = (count, unit);
Err(crate::safety::unimplemented(
"journalctl",
"journalctl reads the systemd journal on Linux, the unified log on macOS and the event log on Windows; NOTHING WAS READ on this operating system",
"cat(path) reads a log file on every OS",
))
}
}
/// An event provider name for journalctl on Windows: letters, digits, space,
/// and - _ . only. The name goes inside a single-quoted PowerShell string, so
/// nothing PowerShell would read as syntax is allowed.
#[cfg(windows)]
fn journal_provider(builtin: &str, what: &str, value: &str) -> Result<String> {
crate::safety::reject_option_like(builtin, &[value.to_string()])?;
let well_formed = !value.trim().is_empty()
&& value.len() <= 256
&& value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b' ' | b'-' | b'_' | b'.'));
if well_formed {
Ok(value.to_string())
} else {
Err(crate::safety::arg_err(format!(
"{builtin}: {what} must be an event provider name (letters, digits, space and - _ .), got {value:?}"
)))
}
}
/// journalctl on Windows: the newest `count` events of the System log, or of
/// one provider. A provider with no events is an empty Array.
/// macOS: the newest `count` entries of the unified log from the last hour, as
/// `log show --style ndjson` prints them: one JSON object per line, returned as
/// Records of the log's own fields, oldest first as journalctl prints them.
/// `unit` is a process name, matched exactly by a predicate that is a single
/// argv element; it passed journal_unit, so it holds no quote or space. The
/// output is read from its end, so a busy hour keeps its newest 16 MiB and the
/// entries come from there. A line that is not JSON is E_TOOL_FAILED.
#[cfg(target_os = "macos")]
fn journal_macos(count: i64, unit: Option<String>) -> Result<Value> {
const NAME: &str = "journalctl";
let predicate = unit.as_deref().map(|u| {
let q = '"';
format!("process == {q}{u}{q}")
});
let mut argv: Vec<&str> = vec!["show", "--style", "ndjson", "--last", "1h"];
if let Some(p) = predicate.as_deref() {
argv.extend(["--predicate", p]);
}
let run = tool_run(
NAME,
"log",
None,
&argv,
Keep::Tail(TOOL_MAX_JSON),
TOOL_DEADLINE_SECS,
)?;
if !run.success {
return Err(crate::safety::tool_failed(
NAME,
"log show --style ndjson",
run.code,
&run.stderr,
));
}
Ok(Value::Array(journal_ndjson_rows(
&run.stdout,
run.stdout_truncated,
count,
)?))
}
/// The newest `count` non-empty lines of `log show --style ndjson` output, each
/// parsed as a JSON object and returned as a Record, oldest first. When the
/// output was cut to its tail (`truncated`), its first line may begin mid-line,
/// so it is dropped. A line that is not JSON is E_TOOL_FAILED, never skipped.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn journal_ndjson_rows(stdout: &str, truncated: bool, count: i64) -> Result<Vec<Value>> {
const NAME: &str = "journalctl";
let mut text = stdout;
if truncated {
text = text.split_once('\n').map_or("", |(_, rest)| rest);
}
// `log show` may print a notice ("Filtering the log data using ...")
// before the first entry; lines before the first JSON object are that
// header. After it, every line must be an entry.
let lines: Vec<&str> = text
.lines()
.filter(|l| !l.trim().is_empty())
.skip_while(|l| !l.trim_start().starts_with('{'))
.collect();
// The stream ends with a summary, {"count": N, "finished": 1}, which is not
// an entry (run 40845679109); it does not take one of the `count` slots.
let mut lines = lines;
if let Some(last) = lines.last() {
let summary = serde_json::from_str::<serde_json::Value>(last)
.ok()
.and_then(|v| {
v.as_object()
.map(|o| o.contains_key("finished") && !o.contains_key("timestamp"))
})
.unwrap_or(false);
if summary {
lines.pop();
}
}
let keep = (count.max(0) as usize).min(lines.len());
lines[lines.len() - keep..]
.iter()
.map(|line| {
serde_json::from_str::<serde_json::Value>(line)
.map(json_to_value)
.map_err(|e| {
crate::safety::tool_failed(
NAME,
"log show --style ndjson",
Some(0),
&format!("printed a line that is not JSON ({e})"),
)
})
})
.collect()
}
#[cfg(test)]
mod macos_readonly_rows {
use super::*;
fn rec_n(v: &Value) -> i64 {
match v {
Value::Record(m) => match m.get("n") {
Some(Value::Int(n)) => *n,
other => panic!("no n: {other:?}"),
},
other => panic!("not a record: {other:?}"),
}
}
#[test]
fn a_notice_before_the_first_entry_is_skipped() {
// Raw strings: this file's source scanners read a backslash-quote as
// the end of a string.
let out = [
r#"Filtering the log data using "process == 1""#,
r#"{"n":1}"#,
r#"{"n":2}"#,
]
.join("\n");
let rows = journal_ndjson_rows(&out, false, 10).expect("rows");
assert_eq!(rows.iter().map(rec_n).collect::<Vec<_>>(), [1, 2]);
let late = [r#"{"n":1}"#, "not json"].join("\n");
assert!(journal_ndjson_rows(&late, false, 10).is_err());
}
#[test]
fn the_closing_summary_is_not_an_entry() {
let out = [r#"{"n":1}"#, r#"{"n":2}"#, r#"{"count":2,"finished":1}"#].join("\n");
let rows = journal_ndjson_rows(&out, false, 1).expect("rows");
assert_eq!(rows.iter().map(rec_n).collect::<Vec<_>>(), [2]);
}
#[test]
fn the_newest_count_lines_come_back_oldest_first() {
let text = concat!(
r#"{"n": 1}"#,
"\n",
r#"{"n": 2}"#,
"\n",
"\n",
r#"{"n": 3}"#,
"\n",
r#"{"n": 4}"#,
"\n",
);
let rows = journal_ndjson_rows(text, false, 2).expect("json lines parse");
assert_eq!(rows.iter().map(rec_n).collect::<Vec<_>>(), vec![3, 4]);
let all = journal_ndjson_rows(text, false, 1000).expect("json lines parse");
assert_eq!(all.len(), 4);
assert!(journal_ndjson_rows(text, false, 0).unwrap().is_empty());
}
#[test]
fn a_tail_that_starts_mid_line_drops_its_partial_first_line() {
let text = concat!(r#"n": 0}"#, "\n", r#"{"n": 7}"#, "\n");
let rows = journal_ndjson_rows(text, true, 5).expect("the partial line is dropped");
assert_eq!(rows.iter().map(rec_n).collect::<Vec<_>>(), vec![7]);
}
#[test]
fn a_line_that_is_not_json_is_a_tool_failure_not_a_skip() {
let text = concat!(r#"{"n": 1}"#, "\n", "Filtering the log data\n");
let err = journal_ndjson_rows(text, false, 5).unwrap_err();
assert!(err.to_string().contains("E_TOOL_FAILED"), "{err}");
}
}
#[cfg(windows)]
fn journal_windows(count: i64, provider: Option<String>) -> Result<Value> {
let filter = match provider.as_deref() {
Some(p) => format!("@{{LogName='System'; ProviderName='{p}'}}"),
None => "@{LogName='System'}".to_string(),
};
let script = WIN_JOURNAL_SCRIPT
.replace("__FILTER__", &filter)
.replace("__COUNT__", &count.to_string());
Ok(Value::Array(win_event_rows("journalctl", &script)?))
}
/// Get-WinEvent streams the newest event first. A filter that matches nothing
/// raises a NoMatching error, caught here and printed as []. Any other error
/// stops the script under ErrorActionPreference Stop and exits non-zero.
#[cfg(windows)]
const WIN_JOURNAL_SCRIPT: &str = r#"$ErrorActionPreference='Stop'; $OutputEncoding=[System.Text.UTF8Encoding]::new($false); [Console]::OutputEncoding=$OutputEncoding; $rows=@(); try { $rows=@(Get-WinEvent -FilterHashtable __FILTER__ -MaxEvents __COUNT__ | ForEach-Object { [pscustomobject]@{ time=$_.TimeCreated.ToString('o'); provider=$_.ProviderName; level=$_.LevelDisplayName; id=$_.Id; message=$_.Message } }) } catch { if ($_.FullyQualifiedErrorId -like 'NoMatching*') { $rows=@() } else { throw } }; ConvertTo-Json -InputObject $rows -Depth 3 -Compress"#;
/// pkg_history on Windows: the MsiInstaller events 1033 (installed) and 1034
/// (removed) in the Application log. They carry the product name and version as
/// properties, and status 0 is success; other statuses are failed transactions
/// and are left out. Select-Object -First stops the read at the limit.
#[cfg(windows)]
const WIN_PKG_HISTORY_SCRIPT: &str = r#"$ErrorActionPreference='Stop'; $OutputEncoding=[System.Text.UTF8Encoding]::new($false); [Console]::OutputEncoding=$OutputEncoding; $rows=@(); try { $rows=@(Get-WinEvent -FilterHashtable @{LogName='Application'; ProviderName='MsiInstaller'; Id=1033,1034} | Where-Object { $_.Properties.Count -ge 4 -and [string]$_.Properties[3].Value -eq '0' } | Select-Object -First __LIMIT__ | ForEach-Object { [pscustomobject]@{ time=$_.TimeCreated.ToString('o'); action=$(if ($_.Id -eq 1033) { 'install' } else { 'remove' }); package=[string]$_.Properties[0].Value; version=[string]$_.Properties[1].Value } }) } catch { if ($_.FullyQualifiedErrorId -like 'NoMatching*') { $rows=@() } else { throw } }; ConvertTo-Json -InputObject $rows -Depth 3 -Compress"#;
/// Run one event-log script under the tool deadline and return its records.
/// The script prints a JSON array. Anything else is an unreadable answer, never
/// an empty one.
#[cfg(windows)]
fn win_event_rows(builtin: &str, script: &str) -> Result<Vec<Value>> {
let mut cmd = crate::safety::child_command("powershell");
cmd.args([
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-Command",
script,
])
.stdin(std::process::Stdio::null());
let output = bounded_cmd_output(builtin, "powershell", cmd, TOOL_DEADLINE_SECS)?;
let text = probe_stdout(builtin, "powershell", output)?;
let items = match serde_json::from_str::<serde_json::Value>(&text) {
Ok(serde_json::Value::Array(items)) => items,
_ => return Err(net_unparsed(builtin, "powershell")),
};
items
.into_iter()
.map(|item| match item {
obj @ serde_json::Value::Object(_) => Ok(json_to_value(obj)),
_ => Err(net_unparsed(builtin, "powershell")),
})
.collect()
}
/// 961: bi_lsof - List open files.
fn bi_lsof(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
linux_diagnostics::no_pipeline_input("lsof", &input)?;
let path = match args.as_slice() {
[] => None,
[Value::Str(p)] => {
linux_diagnostics::file_path("lsof", p)?;
Some(p.clone())
}
[other] => {
return Err(crate::safety::bad_arg(
"lsof",
"a file path String, or no argument",
other.type_name(),
))
}
_ => {
return Err(crate::safety::arg_err(
"lsof: expected no argument, or one file path",
))
}
};
#[cfg(unix)]
{
let limit = std::time::Duration::from_secs(30);
let mut argv = vec!["-P".to_string(), "-n".to_string()];
match &path {
Some(p) => {
if !std::path::Path::new(p).exists() {
return Err(crate::safety::not_found("lsof", "file", p));
}
argv.push(p.clone());
}
None => argv.push("-i".to_string()),
}
let run = linux_diagnostics::run_bounded("lsof", "lsof", "lsof", &argv, limit)?;
// lsof exits 1 when nothing matched; with nothing on stderr that is an
// empty answer, not a failure.
if run.code == Some(1)
&& !run.timed_out
&& !run.truncated
&& run.stdout.trim().is_empty()
&& run.stderr.trim().is_empty()
{
return Ok(Value::Array(vec![]));
}
linux_diagnostics::require_success("lsof", "lsof", &run, limit)?;
let mut lines = run.stdout.lines();
let headers: Vec<String> = match lines.next() {
Some(h) => h.split_whitespace().map(|h| h.to_lowercase()).collect(),
None => return Ok(Value::Array(vec![])),
};
let entries: Vec<Value> = lines
.filter(|line| !line.trim().is_empty())
.map(|line| {
let cols = linux_diagnostics::columns(line, headers.len());
let mut rec = BTreeMap::new();
for (header, col) in headers.iter().zip(cols) {
rec.insert(header.clone(), Value::Str(col.to_string()));
}
Value::Record(rec)
})
.collect();
Ok(Value::Array(entries))
}
#[cfg(windows)]
{
match path {
None => lsof_windows_sockets(),
Some(p) => lsof_windows_path(&p),
}
}
#[cfg(not(any(unix, windows)))]
{
let _ = path;
Err(crate::safety::unimplemented(
"lsof",
"lsof runs on Linux and macOS only; NOTHING WAS LISTED on this operating system",
"netstat_info() lists this machine's network connections on every OS",
))
}
}
/// strace_cmd — trace the system calls of one program and summarise them.
/// Usage: strace_cmd(command). The command is split on whitespace into a program
/// and its arguments and run directly, with no shell; the program may not begin
/// with '-'. Returns {command, exit_code, stdout, raw, syscalls, unparsed_rows}.
/// The exit code is the traced program's, which is the answer here, so it is
/// data and not a failure of the trace. macOS uses dtruss; elsewhere strace, and
/// where it is not installed the call is E_TOOL_MISSING.
fn bi_strace_cmd(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("strace_cmd", &input)?;
k8s_max_args("strace_cmd", &args, 1)?;
let command = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(crate::safety::arg_err(
"strace_cmd: expected command string",
))
}
};
let words = argv_words("strace_cmd", &command)?;
crate::safety::guard_exec("strace", &command)?;
let macos = cfg!(target_os = "macos");
let tracer = if macos { "dtruss" } else { "strace" };
let mut argv: Vec<&str> = if macos {
Vec::new()
} else {
vec!["-c", "-e", "trace=all", "--"]
};
argv.extend(words.iter().map(String::as_str));
let run = tool_run_stderr(
"strace_cmd",
tracer,
None,
&argv,
Keep::Tail(TOOL_MAX_TEXT),
TOOL_MAX_TEXT,
TOOL_DEADLINE_SECS,
)?;
let (syscalls, unparsed) = if macos {
(Vec::new(), 0)
} else {
strace_summary(&run.stderr)
};
let mut rec = BTreeMap::new();
rec.insert("command".to_string(), Value::Str(command));
rec.insert("exit_code".to_string(), Value::Int(exit_code_of(&run)));
rec.insert("stdout".to_string(), Value::Str(run.stdout.clone()));
rec.insert("raw".to_string(), Value::Str(run.stderr.clone()));
rec.insert("syscalls".to_string(), Value::Array(syscalls));
rec.insert("unparsed_rows".to_string(), Value::Int(unparsed as i64));
Ok(Value::Record(rec))
}
/// top — Process snapshot, largest resident memory first (cross-platform via sysinfo).
/// Usage: top_snapshot() or top_snapshot(count), count 1..=10000, default 30.
/// Each entry is {pid, name, cpu_percent, memory_bytes, memory_mb}. CPU use is a
/// difference between two samples, so the call takes one sampling interval.
fn bi_top_snapshot(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("top_snapshot", &input)?;
k8s_max_args("top_snapshot", &args, 1)?;
let count = match args.first() {
None => 30,
Some(Value::Int(n)) if (1..=10_000).contains(n) => *n as usize,
Some(other) => {
return Err(crate::safety::bad_arg(
"top_snapshot",
"count: Int 1..=10000",
other.type_name(),
))
}
};
let mut sys = System::new_all();
sys.refresh_all();
// CPU use is measured between two samples; one sample reads 0 for every process.
std::thread::sleep(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL);
sys.refresh_all();
let mut procs: Vec<_> = sys.processes().iter().collect();
procs.sort_by(|a, b| b.1.memory().cmp(&a.1.memory()));
let entries: Vec<Value> = procs
.iter()
.take(count)
.map(|(pid, p)| {
let mut rec = BTreeMap::new();
rec.insert("pid".to_string(), Value::Int(pid.as_u32() as i64));
rec.insert(
"name".to_string(),
Value::Str(p.name().to_string_lossy().to_string()),
);
rec.insert(
"cpu_percent".to_string(),
Value::Float(p.cpu_usage() as f64),
);
rec.insert("memory_bytes".to_string(), Value::Int(p.memory() as i64));
rec.insert(
"memory_mb".to_string(),
Value::Float(p.memory() as f64 / 1_048_576.0),
);
Value::Record(rec)
})
.collect();
Ok(Value::Array(entries))
}
/// 970: bi_dd_copy - Copy with `dd if=<source> of=<destination>` (Unix; Windows
/// copies in-process, see dd_copy_in_process).
///
/// Each operand is one argv element, so a path cannot add a second operand. It
/// used to drop an options value that was not a Record, any key but `bs` and
/// `count`, and a `count` that was not an Int; a failed copy came back as a
/// record with `success: false`; and `dd` ran with no deadline.
fn bi_dd_copy(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("dd_copy", &input)?;
let text = |i: usize, what: &str| -> Result<String> {
match args.get(i) {
Some(Value::Str(s)) if !s.is_empty() => Ok(s.clone()),
other => Err(crate::safety::bad_arg(
"dd_copy",
&format!("{what}: a non-empty String"),
other.map(|v| v.type_name()).unwrap_or("nothing"),
)),
}
};
let src = text(0, "source")?;
let dst = text(1, "destination")?;
let mut operands = vec![format!("if={src}"), format!("of={dst}")];
match args.get(2) {
None => {}
Some(Value::Record(opts)) => {
for (key, value) in opts {
match (key.as_str(), value) {
("bs", Value::Str(bs)) if dd_block_size(bs) => {
operands.push(format!("bs={bs}"))
}
("bs", Value::Int(n)) if *n >= 1 => operands.push(format!("bs={n}")),
("count", Value::Int(n)) if *n >= 1 => operands.push(format!("count={n}")),
("bs", v) | ("count", v) => {
return Err(crate::safety::bad_arg(
"dd_copy",
"bs: a positive Int or a size such as 4M; count: a positive Int",
&format!("{key} = {}", v.type_name()),
))
}
_ => {
return Err(crate::safety::bad_arg(
"dd_copy",
"options with only bs and count",
key,
))
}
}
}
}
Some(other) => {
return Err(crate::safety::bad_arg(
"dd_copy",
"options: a Record {bs?, count?}",
other.type_name(),
))
}
}
#[cfg(windows)]
{
dd_copy_in_process(&src, &dst, &operands)
}
#[cfg(not(windows))]
{
// dd writes the destination in place, as the in-process copy does.
crate::tx::snapshot(&dst);
let argv: Vec<&str> = operands.iter().map(String::as_str).collect();
let run = tool_run(
"dd_copy",
"dd",
None,
&argv,
Keep::Tail(TOOL_STDERR_TAIL),
TOOL_LONG_DEADLINE_SECS,
)?;
if !run.success {
return Err(crate::safety::tool_failed(
"dd_copy",
&run.command,
run.code,
&run.stderr,
));
}
let mut rec = BTreeMap::new();
rec.insert("success".to_string(), Value::Bool(true));
// dd reports what it copied on stderr.
rec.insert("output".to_string(), Value::Str(run.stderr));
Ok(Value::Record(rec))
}
}
/// Largest block dd_copy's in-process copy reads at once.
#[cfg(windows)]
const DD_BLOCK_MAX: u64 = 64 * 1024 * 1024;
/// The byte count of a `dd` block size already checked by [`dd_block_size`].
#[cfg(windows)]
fn dd_block_bytes(bs: &str) -> Option<u64> {
let (digits, unit) = match bs.char_indices().last() {
Some((i, c)) if c.is_ascii_alphabetic() => {
let unit: u64 = match c {
'c' => 1,
'w' => 2,
'b' => 512,
'k' | 'K' => 1 << 10,
'M' => 1 << 20,
'G' => 1 << 30,
'T' => 1 << 40,
_ => return None,
};
(&bs[..i], unit)
}
_ => (bs, 1),
};
digits.parse::<u64>().ok()?.checked_mul(unit)
}
/// Windows: dd's copy done in-process with the operands dd_copy built. The block
/// size and count are the ones the options gave (512 and no count when absent).
/// The Unix run has a 900 s deadline; this one is checked between blocks. The
/// output is the three report lines dd prints on stderr.
#[cfg(windows)]
fn dd_copy_in_process(src: &str, dst: &str, operands: &[String]) -> Result<Value> {
use std::io::{Read as _, Write as _};
const NAME: &str = "dd_copy";
let bs_text = operands
.iter()
.find_map(|o| o.strip_prefix("bs="))
.unwrap_or("512");
let block = dd_block_bytes(bs_text)
.filter(|n| (1..=DD_BLOCK_MAX).contains(n))
.ok_or_else(|| crate::safety::bad_arg(NAME, "a block size of at most 64M", bs_text))?;
let count: Option<u64> = operands
.iter()
.find_map(|o| o.strip_prefix("count="))
.map(|text| {
text.parse::<u64>()
.map_err(|_| crate::safety::bad_arg(NAME, "a positive Int", text))
})
.transpose()?;
linux_diagnostics::file_path(NAME, src)?;
linux_diagnostics::output_path(NAME, dst)?;
// dd truncates its output before it reads, so a copy onto its own source
// would destroy the source. Refuse it.
let same = match (std::fs::canonicalize(src), std::fs::canonicalize(dst)) {
(Ok(a), Ok(b)) => a == b,
_ => false,
};
if same {
return Err(crate::safety::bad_arg(
NAME,
"a destination other than the source",
dst,
));
}
let mut input = std::fs::File::open(src).map_err(|e| dd_io(NAME, src, &e))?;
// Record dst's pre-image (or its absence) before File::create truncates it.
crate::tx::snapshot(dst);
let mut output = std::fs::File::create(dst).map_err(|e| dd_io(NAME, dst, &e))?;
let started = std::time::Instant::now();
let limit = std::time::Duration::from_secs(TOOL_LONG_DEADLINE_SECS);
let mut buf = vec![0u8; block as usize];
let (mut full_in, mut part_in, mut full_out, mut part_out, mut bytes) =
(0u64, 0u64, 0u64, 0u64, 0u64);
let mut blocks = 0u64;
loop {
if let Some(n) = count {
if blocks >= n {
break;
}
}
if started.elapsed() > limit {
return Err(crate::safety::budget_exceeded(
NAME,
format!("the copy ran past {TOOL_LONG_DEADLINE_SECS} s and was stopped"),
"copy less with count, or in smaller parts",
));
}
let n = match input.read(&mut buf) {
Ok(0) => break,
Ok(n) => n,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => return Err(dd_io(NAME, src, &e)),
};
output
.write_all(&buf[..n])
.map_err(|e| dd_io(NAME, dst, &e))?;
if n as u64 == block {
full_in += 1;
full_out += 1;
} else {
part_in += 1;
part_out += 1;
}
bytes += n as u64;
blocks += 1;
}
output.flush().map_err(|e| dd_io(NAME, dst, &e))?;
let mut rec = BTreeMap::new();
rec.insert("success".to_string(), Value::Bool(true));
rec.insert(
"output".to_string(),
Value::Str(format!(
"{full_in}+{part_in} records in\n{full_out}+{part_out} records out\n{bytes} bytes copied"
)),
);
Ok(Value::Record(rec))
}
/// The coded error for a file dd_copy could not open, read or write.
#[cfg(windows)]
fn dd_io(builtin: &str, path: &str, e: &std::io::Error) -> anyhow::Error {
match e.kind() {
std::io::ErrorKind::NotFound => crate::safety::not_found(builtin, "file", path),
std::io::ErrorKind::PermissionDenied => crate::safety::os_denied(
builtin,
format!("`{path}` is not accessible: {e}"),
"check the path and its permissions",
),
// A device that stops answering, a full disk: still a coded refusal.
_ => crate::safety::bad_state(
builtin,
&format!("`{path}` failed: {e}"),
"a readable source and a writable destination",
),
}
}
/// A `dd` block size: digits with at most one unit suffix (`512`, `4M`, `1k`).
fn dd_block_size(bs: &str) -> bool {
let digits = bs.trim_end_matches(|c: char| "cwbkKMGT".contains(c));
!digits.is_empty()
&& digits.len() <= 12
&& bs.len() - digits.len() <= 1
&& digits.bytes().all(|b| b.is_ascii_digit())
&& digits.bytes().any(|b| b != b'0')
}
/// 975: bi_mount_info - Detailed mount info (cross-platform via sysinfo::Disks).
fn bi_mount_info(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let _ = args;
let disks = Disks::new_with_refreshed_list();
let entries: Vec<Value> = disks
.iter()
.map(|d| {
let mut rec = BTreeMap::new();
rec.insert(
"device".to_string(),
Value::Str(d.name().to_string_lossy().to_string()),
);
rec.insert(
"mount_point".to_string(),
Value::Str(d.mount_point().to_string_lossy().to_string()),
);
rec.insert(
"file_system".to_string(),
Value::Str(String::from_utf8_lossy(d.file_system().as_encoded_bytes()).to_string()),
);
rec.insert(
"total_space".to_string(),
Value::Int(d.total_space() as i64),
);
rec.insert(
"available_space".to_string(),
Value::Int(d.available_space() as i64),
);
rec.insert("is_removable".to_string(), Value::Bool(d.is_removable()));
Value::Record(rec)
})
.collect();
Ok(Value::Array(entries))
}
// ============================================================================
// Remote Access Builtins (980-994)
// SSH, SCP, Rsync, SFTP, RDP, VNC, Telnet, Curl
// ============================================================================
// [removed duplicate] use std::collections::BTreeMap;
// [removed duplicate] use std::process::Command;
// [removed duplicate] use anyhow::{anyhow, Result};
// [removed duplicate] use crate::value::Value;
// ---------------------------------------------------------------------------
// Shared runner for the remote, crypto and archive tools below.
//
// A tool starts from an argv (never a shell), with a null or fixed stdin, under
// a deadline. Its output is bounded, and a run that exceeds a bound is stopped
// and refused (E_BUDGET_EXCEEDED): a cut-off answer reads like a complete one.
// ---------------------------------------------------------------------------
/// Most bytes a tool run may write to captured stdout, or to stderr.
const TOOL_OUTPUT_CAP: u64 = 16 * 1024 * 1024;
/// Most bytes a tool run may write to a file. For xz and zstd this is the
/// decompressed size, which is the bomb a small archive can hide.
const FILE_OUTPUT_CAP: u64 = 1024 * 1024 * 1024;
/// Deadline for a command or transfer that contacts a host, or that may stop
/// for a prompt that nobody will answer.
const NETWORK_DEADLINE_SECS: u64 = 120;
/// Deadline for a local crypto, archive or transfer run.
const LOCAL_DEADLINE_SECS: u64 = 600;
/// Most bytes of JSON text the jq builtins accept.
const JQ_INPUT_CAP: usize = 64 * 1024 * 1024;
/// Options every ssh run gets: no terminal, and no prompt that waits for a
/// person to type. A host that needs a password fails instead of hanging.
const SSH_OPTS: &[&str] = &["-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=20"];
/// The scp equivalent of [`SSH_OPTS`].
const SCP_OPTS: &[&str] = &["-o", "BatchMode=yes", "-o", "ConnectTimeout=20"];
const HALT_NONE: u8 = 0;
const HALT_OVER_CAP: u8 = 1;
const HALT_IO: u8 = 2;
/// Copies one pipe into `sink` until the pipe closes. When the stream passes
/// `cap`, or a read or write fails, it records why in `halt` and stops.
fn pump<R, W>(
mut pipe: R,
sink: std::sync::Arc<std::sync::Mutex<W>>,
cap: u64,
halt: std::sync::Arc<std::sync::atomic::AtomicU8>,
done: std::sync::mpsc::Sender<()>,
) where
R: std::io::Read + Send + 'static,
W: std::io::Write + Send + 'static,
{
use std::io::ErrorKind;
use std::sync::atomic::Ordering;
std::thread::spawn(move || {
let mut total: u64 = 0;
let mut chunk = [0u8; 8192];
loop {
match pipe.read(&mut chunk) {
Ok(0) => break,
Ok(n) => {
total = total.saturating_add(n as u64);
if total > cap {
halt.store(HALT_OVER_CAP, Ordering::SeqCst);
break;
}
let mut w = sink
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if w.write_all(&chunk[..n]).is_err() {
halt.store(HALT_IO, Ordering::SeqCst);
break;
}
}
Err(e) if e.kind() == ErrorKind::Interrupted => {}
Err(_) => {
halt.store(HALT_IO, Ordering::SeqCst);
break;
}
}
}
let _ = done.send(());
});
}
/// The refusal for a run that was stopped: over a cap, a failed pipe, or the deadline.
fn halt_error(builtin: &str, tool: &str, flag: u8, secs: u64, cap: u64) -> anyhow::Error {
match flag {
HALT_OVER_CAP => crate::safety::budget_exceeded(
builtin,
format!("{tool} wrote more than {cap} bytes and was stopped"),
"the output would be cut short, so it is refused; narrow the request",
),
HALT_IO => crate::safety::tool_failed(
builtin,
tool,
None,
"its output could not be read or written",
),
_ => crate::safety::budget_exceeded(
builtin,
format!("{tool} did not finish within {secs} s and was stopped"),
"the call is refused rather than answered in part; check the host, file or network and try again",
),
}
}
/// Runs `cmd` for at most `secs`. Stdout goes to `stdout`; stderr is captured.
/// Returns the exit status and the stderr text. A run that exceeds `cap`, or
/// its deadline, is killed and refused. A nonzero exit is not an error here;
/// see [`tool_ok`].
fn run_tool<W>(
builtin: &str,
tool: &str,
mut cmd: std::process::Command,
stdin: Option<Vec<u8>>,
stdout: std::sync::Arc<std::sync::Mutex<W>>,
secs: u64,
cap: u64,
) -> Result<(std::process::ExitStatus, String)>
where
W: std::io::Write + Send + 'static,
{
use std::io::Write as _;
use std::process::Stdio;
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::{mpsc, Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
let halt = Arc::new(AtomicU8::new(HALT_NONE));
crate::safety::strip_ironshell_env(&mut cmd);
cmd.stdin(if stdin.is_some() {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(unix)]
{
// The child leads its own group, so a deadline or cap kills its whole tree.
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
let mut child = cmd
.spawn()
.map_err(|e| crate::safety::spawn_error(builtin, tool, &e))?;
#[cfg(windows)]
let job = TreeJob::for_child(&child);
let (Some(out_pipe), Some(err_pipe)) = (child.stdout.take(), child.stderr.take()) else {
#[cfg(windows)]
kill_job(job.as_ref());
kill_tree(&mut child);
return Err(crate::safety::tool_failed(
builtin,
tool,
None,
"its output pipes were not opened",
));
};
if let (Some(payload), Some(mut pipe)) = (stdin, child.stdin.take()) {
// Detached: a child that never reads its stdin must not hold this call.
// A failed write just ends early, and the exit status reports why.
std::thread::spawn(move || {
let _ = pipe.write_all(&payload);
});
}
let stderr = Arc::new(Mutex::new(Vec::<u8>::new()));
let (out_done, out_closed) = mpsc::channel();
let (err_done, err_closed) = mpsc::channel();
pump(out_pipe, stdout, cap, Arc::clone(&halt), out_done);
pump(
err_pipe,
Arc::clone(&stderr),
cap,
Arc::clone(&halt),
err_done,
);
let limit = Duration::from_secs(secs);
let started = Instant::now();
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) => {}
Err(e) => {
#[cfg(windows)]
kill_job(job.as_ref());
kill_tree(&mut child);
return Err(crate::safety::tool_failed(
builtin,
tool,
None,
&e.to_string(),
));
}
}
if halt.load(Ordering::SeqCst) != HALT_NONE || started.elapsed() >= limit {
#[cfg(windows)]
kill_job(job.as_ref());
kill_tree(&mut child);
return Err(halt_error(
builtin,
tool,
halt.load(Ordering::SeqCst),
secs,
cap,
));
}
std::thread::sleep(Duration::from_millis(10));
};
// The pipes close with the child. A grandchild that keeps one open must not
// hold the call, and output that has not closed is not returned.
let wait = Duration::from_secs(2);
let closed = out_closed.recv_timeout(wait).is_ok() && err_closed.recv_timeout(wait).is_ok();
let flag = halt.load(Ordering::SeqCst);
if !closed || flag != HALT_NONE {
// A descendant still holds a pipe, or the run was halted: kill its group
// so nothing it started keeps running after the call returns.
#[cfg(windows)]
kill_job(job.as_ref());
kill_group(child.id());
}
if flag != HALT_NONE {
return Err(halt_error(builtin, tool, flag, secs, cap));
}
if !closed {
return Err(crate::safety::tool_failed(
builtin,
tool,
status.code(),
"its output stayed open after it exited",
));
}
let bytes = stderr.lock().unwrap_or_else(PoisonError::into_inner);
Ok((status, String::from_utf8_lossy(&bytes).into_owned()))
}
/// A nonzero exit is a coded tool failure, with the exit status and the tail of stderr.
fn tool_ok(
builtin: &str,
tool: &str,
status: std::process::ExitStatus,
stderr: &str,
) -> Result<()> {
if status.success() {
return Ok(());
}
Err(crate::safety::tool_failed(
builtin,
tool,
status.code(),
stderr_tail(stderr),
))
}
/// The last 4 KiB of a tool's stderr, for an error message.
fn stderr_tail(text: &str) -> &str {
const KEEP: usize = 4096;
let text = text.trim();
if text.len() <= KEEP {
return text;
}
let mut start = text.len() - KEEP;
while !text.is_char_boundary(start) {
start += 1;
}
text[start..].trim()
}
/// Runs a tool that answers on stdout. Returns that text and the stderr text
/// once the tool exits 0; any other exit is a coded tool failure.
fn run_checked(
builtin: &str,
tool: &str,
cmd: std::process::Command,
secs: u64,
) -> Result<(String, String)> {
let out = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
let (status, stderr) = run_tool(
builtin,
tool,
cmd,
None,
std::sync::Arc::clone(&out),
secs,
TOOL_OUTPUT_CAP,
)?;
tool_ok(builtin, tool, status, &stderr)?;
let bytes = std::mem::take(
&mut *out
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
);
Ok((String::from_utf8_lossy(&bytes).into_owned(), stderr))
}
/// Runs a tool whose stdout is the new file `out_path`. The file is created
/// exclusively, so an existing file is refused, and it is removed when the run fails.
fn tool_to_new_file(builtin: &str, tool: &str, argv: &[&str], out_path: &str) -> Result<()> {
let file = create_new_output(builtin, out_path)?;
let sink = std::sync::Arc::new(std::sync::Mutex::new(file));
let mut cmd = crate::safety::child_command(tool);
cmd.args(argv);
let result = run_tool(
builtin,
tool,
cmd,
None,
sink,
LOCAL_DEADLINE_SECS,
FILE_OUTPUT_CAP,
)
.and_then(|(status, stderr)| tool_ok(builtin, tool, status, &stderr));
if result.is_err() {
remove_partial(out_path);
}
result
}
/// Creates a new file for a tool's output. Existing files are refused, and the
/// check and the create are one open, so a file that appears meanwhile is refused too.
fn create_new_output(builtin: &str, path: &str) -> Result<std::fs::File> {
// Record that the path did not exist, so a rollback removes the file this
// creates. A safety net: the tool_to_new_file builtins (tar_create, bzip2, xz,
// zstd) are in tx::IRREVERSIBLE and refused inside a transaction before they
// get here, and the gzip builtins snapshot their output themselves.
crate::tx::snapshot(path);
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.map_err(|e| {
if e.kind() == std::io::ErrorKind::AlreadyExists {
crate::safety::bad_state(
builtin,
&format!("{path} already exists"),
"remove it first; the existing file is not overwritten",
)
} else {
crate::safety::fs_error(builtin, path, &e)
}
})
}
/// Refuses an output path that already exists, for a tool that writes its own file.
fn refuse_existing(builtin: &str, path: &str) -> Result<()> {
if std::path::Path::new(path).exists() {
return Err(crate::safety::bad_state(
builtin,
&format!("{path} already exists"),
"remove it first; the existing file is not overwritten",
));
}
Ok(())
}
/// Removes a file a failed run may have left. Only ever called on a path that
/// was checked absent before the run, so the file is the run's own. A failure
/// other than absence is logged; output that may be secret, or that the caller
/// is told is gone, goes through `fail_removing_partial` so the error names it.
fn remove_partial(path: &str) {
if let Err(e) = std::fs::remove_file(path) {
if e.kind() != std::io::ErrorKind::NotFound {
tracing::warn!("{path} was left behind by a failed run and could not be removed: {e}");
}
}
}
/// Removes a failed run's output and returns the run's error, naming the file if
/// it could not be removed. For output that may be secret or that the caller is
/// told is gone: the caller has to know it is still there.
fn fail_removing_partial(error: anyhow::Error, path: &str) -> anyhow::Error {
match std::fs::remove_file(path) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => crate::safety::with_note(
error,
&format!("{path} was left behind and could not be removed: {e}"),
),
_ => error,
}
}
#[cfg(test)]
mod partial_cleanup_tests {
use super::fail_removing_partial;
#[test]
fn a_partial_that_cannot_be_removed_is_named_and_keeps_the_code() {
// remove_file never deletes a directory, on any platform, so a directory
// stands in for a partial output that cannot be removed.
let dir = std::env::temp_dir().join(format!("ish-partial-cleanup-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create the stand-in directory");
let dir_str = dir.to_str().expect("temp path is UTF-8");
let err = fail_removing_partial(crate::safety::bad_arg("t", "x", "y"), dir_str);
let plain = crate::safety::bad_arg("t", "x", "y");
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(crate::safety::code_of(&err), crate::safety::code_of(&plain));
// Where an agent reads it: the coded error, not an anyhow context layer.
let coded = err
.downcast_ref::<crate::safety::SafetyError>()
.expect("still a coded error");
assert!(coded.message.contains("left behind"), "got: {coded}");
}
#[test]
fn a_partial_already_gone_adds_nothing() {
let path = std::env::temp_dir().join(format!("ish-partial-absent-{}", std::process::id()));
let _ = std::fs::remove_file(&path);
let path_str = path.to_str().expect("temp path is UTF-8");
let expected = format!("{:#}", crate::safety::bad_arg("t", "x", "y"));
let err = fail_removing_partial(crate::safety::bad_arg("t", "x", "y"), path_str);
assert_eq!(format!("{err:#}"), expected);
}
}
/// A caller value in a positional slot, refused when it could be read as an option.
fn refuse_option(builtin: &str, value: &str) -> Result<()> {
crate::safety::reject_option_like(builtin, &[value.to_string()])
}
/// The path must name an existing regular file.
fn regular_file_arg(builtin: &str, path: &str) -> Result<()> {
let meta = std::fs::metadata(path).map_err(|e| crate::safety::fs_error(builtin, path, &e))?;
if !meta.is_file() {
return Err(crate::safety::bad_arg(builtin, "a regular file", path));
}
Ok(())
}
/// A `[user@]host` for ssh, scp and ssh-copy-id. It is refused when it could be
/// read as an option (`-oProxyCommand=…`), when it is empty, or when it holds a
/// character a host name, address or user name does not use.
fn ssh_target_arg(builtin: &str, what: &str, target: &str) -> Result<()> {
refuse_option(builtin, target)?;
let (user, host) = match target.rsplit_once('@') {
Some((user, host)) => (Some(user), host),
None => (None, target),
};
let chars_ok = target.chars().all(|c| {
c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':' | '[' | ']' | '%' | '@')
});
let user_ok = match user {
Some(user) => !user.is_empty() && !user.starts_with('-'),
None => true,
};
if !target.is_empty()
&& target.len() <= 255
&& chars_ok
&& user_ok
&& !host.is_empty()
&& !host.starts_with('-')
{
return Ok(());
}
Err(crate::safety::bad_arg(
builtin,
&format!("{what} as [user@]host of letters, digits and . _ - : [ ] % @"),
target,
))
}
/// A tunnel's destination: a host name, an IPv4 address, or a bracketed IPv6 address.
fn tunnel_host_arg(builtin: &str, host: &str) -> Result<()> {
refuse_option(builtin, host)?;
let ok = match host.strip_prefix('[').and_then(|h| h.strip_suffix(']')) {
Some(addr) => {
!addr.is_empty()
&& addr
.chars()
.all(|c| c.is_ascii_hexdigit() || matches!(c, ':' | '.'))
}
None => {
!host.is_empty()
&& host.len() <= 253
&& host
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
}
};
if ok {
return Ok(());
}
Err(crate::safety::bad_arg(
builtin,
"remote_host as a host name, an IPv4 address or a bracketed IPv6 address",
host,
))
}
/// An scp remote, `[user@]host:path`. The host is checked as [`ssh_target_arg`]
/// checks it. The path must be one file: it may not hold a shell
/// metacharacter, a glob, or a control character, because the remote side may
/// read it through a shell.
fn scp_remote_arg(builtin: &str, remote: &str) -> Result<()> {
let bad = || crate::safety::bad_arg(builtin, "a remote of the form [user@]host:path", remote);
// A user name ends at the first '@' that comes before any ':' or '/'. A
// later '@' belongs to the path.
let user_len = match remote.find('@') {
Some(i) if !remote[..i].contains([':', '/']) => i + 1,
_ => 0,
};
let rest = &remote[user_len..];
let colon = if rest.starts_with('[') {
rest.find("]:").map(|i| i + 1)
} else {
rest.find(':')
};
let Some(colon) = colon else {
return Err(bad());
};
ssh_target_arg(builtin, "remote host", &remote[..user_len + colon])?;
let path = &remote[user_len + colon + 1..];
if path.is_empty() {
return Err(bad());
}
if path
.chars()
.any(|c| c.is_control() || "`$;&|<>()'\"\\{}*?[]".contains(c))
{
return Err(crate::safety::bad_arg(
builtin,
"a remote path with no shell or glob characters (one file)",
path,
));
}
Ok(())
}
/// The host of an scp remote that [`scp_remote_arg`] has accepted: the part
/// between the user's `@` and the `:` that ends it (a bracketed address keeps its
/// brackets).
fn scp_remote_host(remote: &str) -> &str {
let rest = match remote.find('@') {
Some(i) if !remote[..i].contains([':', '/']) => &remote[i + 1..],
_ => remote,
};
let end = if rest.starts_with('[') {
rest.find("]:").map_or(rest.len(), |i| i + 1)
} else {
rest.find(':').unwrap_or(rest.len())
};
&rest[..end]
}
/// A local scp operand. scp reads a path with a ':' in it as a host, so a local
/// path must not have one, apart from a Windows drive letter.
fn scp_local_arg(builtin: &str, path: &str) -> Result<()> {
refuse_option(builtin, path)?;
let bytes = path.as_bytes();
let rest = if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
&path[2..]
} else {
path
};
if rest.contains(':') {
return Err(crate::safety::bad_arg(
builtin,
"a local path without ':' (scp would read it as a host)",
path,
));
}
Ok(())
}
/// The text, when it is one OpenSSH public key line: a key type, a base64 blob
/// and an optional comment, and nothing else. Anything else is refused by the
/// caller, and its content is not echoed.
fn public_key_line(text: &str) -> Option<&str> {
let line = text.trim();
if line.is_empty() || line.len() > 16 * 1024 || line.contains(['\n', '\r']) {
return None;
}
let mut parts = line.splitn(3, ' ');
let kind = parts.next()?;
let blob = parts.next()?;
let kind_ok =
(kind.starts_with("ssh-") || kind.starts_with("ecdsa-sha2-") || kind.starts_with("sk-"))
&& kind
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '@' | '.'));
let blob_ok = !blob.is_empty()
&& blob
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '='));
let comment_ok = match parts.next() {
None => true,
Some(comment) => comment
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '@' | '.' | '_' | '-' | '+' | ' ')),
};
if kind_ok && blob_ok && comment_ok {
Some(line)
} else {
None
}
}
// 980: bi_ssh_exec - Run one command on a remote host over ssh.
//
// The command is one string and the remote account's login shell reads it, so
// the remote side expands it as that shell does. The host never reaches a
// local shell. Exit 0 returns the output. Any other exit is E_TOOL_FAILED with
// the exit status and the tail of stderr. Output past the cap, or a run past
// its deadline, is refused.
fn bi_ssh_exec(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("ssh_exec", &input)?;
let host = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(crate::safety::arg_err(
"ssh_exec: first argument must be host string (user@host)",
))
}
};
let command = match args.get(1) {
Some(Value::Str(s)) if !s.trim().is_empty() && !s.contains('\0') => s.clone(),
_ => {
return Err(crate::safety::arg_err(
"ssh_exec: second argument must be a non-empty command string",
))
}
};
let port = match args.get(2) {
Some(Value::Int(p)) => Some(*p),
_ => None,
};
ssh_target_arg("ssh_exec", "host", &host)?;
// The destination is the host argument as written. An ssh config alias that
// is not a dotted name is refused by the allowlist (no host can be read), and
// a dotted alias is judged by its name, not by the HostName ssh config gives it.
let egress_host = crate::safety::ssh_egress_host("ssh_exec", &host)?;
crate::safety::check_egress("ssh_exec", std::slice::from_ref(&egress_host))?;
// Runs an arbitrary command on another host: the widest blast radius in the
// builtin table. Checked after the arguments, so a refused call is not audited
// as a run.
crate::safety::guard_exec("ssh_exec", format!("ssh {} {}", host, command))?;
let mut cmd = crate::safety::child_command("ssh");
cmd.args(SSH_OPTS);
if let Some(p) = port {
cmd.arg("-p").arg(p.to_string());
}
cmd.arg(&host).arg(&command);
let (stdout, stderr) = run_checked("ssh_exec", "ssh", cmd, NETWORK_DEADLINE_SECS)?;
Ok(rec(vec![
("stdout", s(&stdout)),
("stderr", s(&stderr)),
("exit_code", Value::Int(0)),
("success", Value::Bool(true)),
]))
}
// 981: bi_ssh_tunnel - Open a local port forward through an ssh host.
//
// ssh runs in the foreground (`-N`, not `-f`), so this process holds the ssh
// pid and the call answers it: proc_kill(pid) stops the tunnel. The call returns
// once the forward is listening, or fails when ssh exits or the deadline passes.
// The local port must be free before ssh starts: a connect to a port something
// else holds would report a tunnel that ssh never opened. ExitOnForwardFailure
// makes a port that cannot be bound an error rather than a silent tunnel that
// does nothing.
fn bi_ssh_tunnel(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("ssh_tunnel", &input)?;
let local_port = match args.first() {
Some(Value::Int(p)) => *p,
_ => {
return Err(crate::safety::arg_err(
"ssh_tunnel: first argument must be local_port (Int)",
))
}
};
let remote_host = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(crate::safety::arg_err(
"ssh_tunnel: second argument must be remote_host string",
))
}
};
let remote_port = match args.get(2) {
Some(Value::Int(p)) => *p,
_ => {
return Err(crate::safety::arg_err(
"ssh_tunnel: third argument must be remote_port (Int)",
))
}
};
let ssh_host = match args.get(3) {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(crate::safety::arg_err(
"ssh_tunnel: fourth argument must be ssh_host string",
))
}
};
for (what, port) in [("local_port", local_port), ("remote_port", remote_port)] {
if !(1..=65535).contains(&port) {
return Err(crate::safety::bad_arg(
"ssh_tunnel",
&format!("{what} in 1..=65535"),
&port.to_string(),
));
}
}
tunnel_host_arg("ssh_tunnel", &remote_host)?;
ssh_target_arg("ssh_tunnel", "ssh_host", &ssh_host)?;
// Both named hosts are judged: ssh_host is connected to, and remote_host is
// the destination the forward reaches through it. Each is judged by its name.
// remote_host is not passed through `ssh_egress_host`, on purpose: in
// `ssh -L port:remote_host:port host` the far end opens the connection to
// remote_host with its own resolver, so the local ~/.ssh/config (which only
// rewrites the destination ssh itself connects to) has no bearing on it.
// The name as written is what the server will be asked to reach.
let ssh_egress = crate::safety::ssh_egress_host("ssh_tunnel", &ssh_host)?;
crate::safety::check_egress("ssh_tunnel", &[ssh_egress, remote_host.clone()])?;
// Checked above to be in 1..=65535.
let port = local_port as u16;
// Refused when something already holds the port. The probe listener is
// dropped here, before ssh is started. A bind error other than "in use"
// (for example a privileged port) is refused the same way, with the OS text.
match std::net::TcpListener::bind(("127.0.0.1", port)) {
Ok(listener) => drop(listener),
Err(e) => {
return Err(crate::safety::bad_arg(
"ssh_tunnel",
&format!("a local_port that is free on 127.0.0.1 (bind said: {e})"),
&local_port.to_string(),
))
}
}
let spec = format!("{local_port}:{remote_host}:{remote_port}");
// ssh's stderr goes to a new file, not a pipe: nothing reads a pipe after
// this call returns, and a full pipe would block ssh. The file name carries
// the port and this process's pid, plus a uuid so a stale file cannot collide.
let stderr_path = std::env::temp_dir().join(format!(
"ae-ssh-tunnel-{local_port}-{}-{}.err",
std::process::id(),
uuid::Uuid::new_v4().simple()
));
let stderr_path = stderr_path.to_string_lossy().into_owned();
let stderr_file = create_new_output("ssh_tunnel", &stderr_path)?;
let mut cmd = crate::safety::child_command("ssh");
crate::safety::strip_ironshell_env(&mut cmd);
cmd.args(["-N", "-o", "ExitOnForwardFailure=yes"])
.args(SSH_OPTS)
.arg("-L")
.arg(&spec)
.arg(&ssh_host)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(stderr_file);
let mut child = match cmd.spawn() {
Ok(child) => child,
Err(e) => {
remove_partial(&stderr_path);
return Err(crate::safety::spawn_error("ssh_tunnel", "ssh", &e));
}
};
drop(cmd);
// Wait until the forward listens, ssh exits, or the deadline passes. The
// deadline is the network one, or the evaluation's own if that is sooner.
let mut limit = std::time::Duration::from_secs(NETWORK_DEADLINE_SECS);
if let Some(left) = crate::safety::remaining_deadline() {
limit = limit.min(left);
}
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
let started = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) => {
let stderr = std::fs::read_to_string(&stderr_path).unwrap_or_default();
remove_partial(&stderr_path);
return Err(crate::safety::tool_failed(
"ssh_tunnel",
"ssh",
status.code(),
&tail_text(stderr.trim(), 300),
));
}
Ok(None) => {}
Err(e) => {
let _ = child.kill();
let _ = child.wait();
remove_partial(&stderr_path);
return Err(crate::safety::tool_failed(
"ssh_tunnel",
"ssh",
None,
&e.to_string(),
));
}
}
if std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(200))
.is_ok()
{
break;
}
if started.elapsed() >= limit {
let _ = child.kill();
let _ = child.wait();
remove_partial(&stderr_path);
return Err(crate::safety::budget_exceeded(
"ssh_tunnel",
format!(
"ssh did not open the forward on 127.0.0.1:{port} within {:.1} s and was stopped",
limit.as_secs_f64()
),
"the call is refused rather than answered in part; check the host, its key authentication and the network, and try again",
));
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
// The forward is up. The child is not waited for: dropping it does not kill
// it, and it is the tunnel. Its pid is the answer, and proc_wait may reap it.
remove_partial(&stderr_path);
let pid = i64::from(child.id());
spawned_note(pid);
Ok(rec(vec![
("success", Value::Bool(true)),
("pid", Value::Int(pid)),
("local_port", Value::Int(local_port)),
("remote_host", Value::Str(remote_host)),
("remote_port", Value::Int(remote_port)),
("ssh_host", Value::Str(ssh_host)),
]))
}
// 982: bi_ssh_keygen - Generate an SSH key pair.
//
// The private key is written with no passphrase (`-N ""`), so anyone who can
// read the file can use the key. A key or public-key file that already exists
// is refused, not replaced. The jail applies to the private key's path, so a
// default under ~/.ssh is refused unless that path is inside the workspace.
fn bi_ssh_keygen(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("ssh_keygen", &input)?;
let key_type = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => "ed25519".to_string(),
};
if !matches!(key_type.as_str(), "ed25519" | "rsa" | "ecdsa") {
return Err(crate::safety::bad_arg(
"ssh_keygen",
"type: ed25519, rsa or ecdsa",
&key_type,
));
}
let filename = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
_ => {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| ".".to_string());
format!("{}/.ssh/id_{}", home, key_type)
}
};
refuse_option("ssh_keygen", &filename)?;
// The jail is checked first, so the existence test looks at the path the
// key would really be written to.
let filename = guard_local_write("ssh_keygen", &filename)?;
let public = format!("{filename}.pub");
refuse_existing("ssh_keygen", &filename)?;
refuse_existing("ssh_keygen", &public)?;
let mut cmd = crate::safety::child_command("ssh-keygen");
cmd.args(["-t", key_type.as_str(), "-f", filename.as_str(), "-N", ""]);
match run_checked("ssh_keygen", "ssh-keygen", cmd, LOCAL_DEADLINE_SECS) {
Ok((output, _)) => Ok(rec(vec![
("passphrase", Value::Bool(false)),
("type", Value::Str(key_type)),
("private_key", Value::Str(filename)),
("public_key", Value::Str(public)),
("output", Value::Str(output)),
])),
Err(e) => {
// Both paths were checked absent above, so anything there now is ours.
Err(fail_removing_partial(
fail_removing_partial(e, &filename),
&public,
))
}
}
}
// 983: bi_ssh_copy_id - Install a public key in a remote account's authorized_keys.
//
// This writes to the remote host. The key must be one OpenSSH public-key line,
// so a private key or a stray file is refused without its content being shown.
// On Windows the line is appended by a remote shell command. Its quoting relies
// on the key holding no quote character, which public_key_line guarantees, and
// a grep keeps a second copy from being appended.
fn bi_ssh_copy_id(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("ssh_copy_id", &input)?;
let host = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(crate::safety::arg_err(
"ssh_copy_id: first argument must be host string (user@host)",
))
}
};
let key_file = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
_ => {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| ".".to_string());
format!("{}/.ssh/id_ed25519.pub", home)
}
};
ssh_target_arg("ssh_copy_id", "host", &host)?;
let egress_host = crate::safety::ssh_egress_host("ssh_copy_id", &host)?;
crate::safety::check_egress("ssh_copy_id", std::slice::from_ref(&egress_host))?;
refuse_option("ssh_copy_id", &key_file)?;
let key_file = crate::safety::resolve_path_str(&key_file);
regular_file_arg("ssh_copy_id", &key_file)?;
let size = std::fs::metadata(&key_file).map(|m| m.len()).unwrap_or(0);
if size > 16 * 1024 {
return Err(crate::safety::bad_arg(
"ssh_copy_id",
"key_file: an OpenSSH public key",
&key_file,
));
}
let text = read_text_capped("ssh_copy_id", &key_file)?;
let Some(key) = public_key_line(&text) else {
return Err(crate::safety::bad_arg(
"ssh_copy_id",
"key_file: an OpenSSH public key (type, base64, optional comment)",
&key_file,
));
};
if cfg!(target_os = "windows") {
// ssh-copy-id may not be installed here, so the key is appended by hand.
let remote = format!(
"mkdir -p ~/.ssh && (grep -qxF '{key}' ~/.ssh/authorized_keys 2>/dev/null || echo '{key}' >> ~/.ssh/authorized_keys) && chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys"
);
let mut cmd = crate::safety::child_command("ssh");
cmd.args(["-T", "-o", "ConnectTimeout=20"])
.arg(&host)
.arg(&remote);
run_checked("ssh_copy_id", "ssh", cmd, NETWORK_DEADLINE_SECS)?;
Ok(rec(vec![
("success", Value::Bool(true)),
("method", s("manual")),
("host", Value::Str(host)),
]))
} else {
let mut cmd = crate::safety::child_command("ssh-copy-id");
cmd.args(["-i", key_file.as_str()]).arg(&host);
run_checked("ssh_copy_id", "ssh-copy-id", cmd, NETWORK_DEADLINE_SECS)?;
Ok(rec(vec![
("success", Value::Bool(true)),
("method", s("ssh-copy-id")),
("host", Value::Str(host)),
]))
}
}
/// The value of an ssh config `Port` line: a decimal number from 1 to 65535.
/// Anything else is `None`.
fn ssh_port_value(val: &str) -> Option<i64> {
val.trim()
.parse::<u16>()
.ok()
.filter(|p| *p != 0)
.map(i64::from)
}
// 984: bi_ssh_config - Read and parse SSH config file
fn bi_ssh_config(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let config_path = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| ".".to_string());
format!("{}/.ssh/config", home)
}
};
// A missing file is E_NOT_FOUND from the read; a denied or oversized one
// keeps its own code. Every failure used to read as "not found".
let content = read_text_capped("ssh_config", &config_path)?;
let mut hosts: Vec<Value> = Vec::new();
let mut current: Option<BTreeMap<String, Value>> = None;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let parts: Vec<&str> = trimmed.splitn(2, char::is_whitespace).collect();
if parts.len() < 2 {
continue;
}
let key = parts[0].to_lowercase();
let val = parts[1].trim().to_string();
if key == "host" {
if let Some(entry) = current.take() {
hosts.push(Value::Record(entry));
}
let mut entry = BTreeMap::new();
entry.insert("Host".to_string(), Value::Str(val));
current = Some(entry);
} else if let Some(ref mut entry) = current {
match key.as_str() {
"hostname" => {
entry.insert("HostName".to_string(), Value::Str(val));
}
"user" => {
entry.insert("User".to_string(), Value::Str(val));
}
"port" => {
// A malformed Port used to become 22, a connection to a
// port the host may not serve. It is refused instead.
let port = ssh_port_value(&val).ok_or_else(|| {
let host = match entry.get("Host") {
Some(Value::Str(h)) => h.as_str(),
_ => "?",
};
crate::safety::bad_arg(
"ssh_config",
"a Port number from 1 to 65535",
&format!("Port `{val}` for host `{host}`"),
)
})?;
entry.insert("Port".to_string(), Value::Int(port));
}
"identityfile" => {
entry.insert("IdentityFile".to_string(), Value::Str(val));
}
other => {
entry.insert(other.to_string(), Value::Str(val));
}
}
}
}
if let Some(entry) = current {
hosts.push(Value::Record(entry));
}
Ok(Value::Array(hosts))
}
// 985: bi_scp_upload - Copy one local file to a remote host with scp.
//
// The remote side is validated before the local file is looked at, so a bad
// remote is refused without touching the disk.
fn bi_scp_upload(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("scp_upload", &input)?;
let local_path = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(crate::safety::arg_err(
"scp_upload: first argument must be local_path string",
))
}
};
let remote = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(crate::safety::arg_err(
"scp_upload: second argument must be remote string (user@host:path)",
))
}
};
let port = match args.get(2) {
Some(Value::Int(p)) => Some(*p),
_ => None,
};
scp_remote_arg("scp_upload", &remote)?;
let egress_host = crate::safety::ssh_egress_host("scp_upload", scp_remote_host(&remote))?;
crate::safety::check_egress("scp_upload", &[egress_host])?;
let local_path = crate::safety::resolve_path_str(&local_path);
scp_local_arg("scp_upload", &local_path)?;
regular_file_arg("scp_upload", &local_path)?;
let mut cmd = crate::safety::child_command("scp");
cmd.args(SCP_OPTS);
if let Some(p) = port {
cmd.arg("-P").arg(p.to_string());
}
cmd.arg(&local_path).arg(&remote);
run_checked("scp_upload", "scp", cmd, LOCAL_DEADLINE_SECS)?;
Ok(rec(vec![
("success", Value::Bool(true)),
("local_path", Value::Str(local_path)),
("remote", Value::Str(remote)),
]))
}
// 986: bi_scp_download - Copy one remote file to a new local file with scp.
//
// The local destination is jailed like any write and must not exist: scp
// would otherwise replace it without a word. A failed transfer leaves no file.
fn bi_scp_download(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("scp_download", &input)?;
let remote = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(crate::safety::arg_err(
"scp_download: first argument must be remote string (user@host:path)",
))
}
};
let local_path = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(crate::safety::arg_err(
"scp_download: second argument must be local_path string",
))
}
};
let port = match args.get(2) {
Some(Value::Int(p)) => Some(*p),
_ => None,
};
scp_remote_arg("scp_download", &remote)?;
let egress_host = crate::safety::ssh_egress_host("scp_download", scp_remote_host(&remote))?;
crate::safety::check_egress("scp_download", &[egress_host])?;
refuse_option("scp_download", &local_path)?;
// Self-guarded for the local write, so the fetch is guarded here too, as
// `web_download` does: otherwise the central Network gate is skipped.
guard_network("scp_download", &remote)?;
let local_path = guard_local_write("scp_download", &local_path)?;
scp_local_arg("scp_download", &local_path)?;
refuse_existing("scp_download", &local_path)?;
let mut cmd = crate::safety::child_command("scp");
cmd.args(SCP_OPTS);
if let Some(p) = port {
cmd.arg("-P").arg(p.to_string());
}
cmd.arg(&remote).arg(&local_path);
if let Err(e) = run_checked("scp_download", "scp", cmd, LOCAL_DEADLINE_SECS) {
remove_partial(&local_path);
return Err(e);
}
Ok(rec(vec![
("success", Value::Bool(true)),
("remote", Value::Str(remote)),
("local_path", Value::Str(local_path)),
]))
}
// 987: bi_rsync_sync - Rsync synchronization
/// 987 – rsync_sync(source, destination, [options]): rsync with a local path
/// inside the workspace or a `[user@]host:path` remote spec on either side. The
/// options Record names archive, compress, delete, verbose, progress and exclude;
/// with no Record the default is `-az`. A nonzero exit is E_TOOL_FAILED. Runs
/// with a 15 minute deadline. `delete` removes files on the destination.
fn bi_rsync_sync(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "rsync_sync";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 3)?;
let source = k8s_need(B, "a source path or remote spec", &args, 0, ops_sync_path)?;
let destination = k8s_need(
B,
"a destination path or remote spec",
&args,
1,
ops_sync_path,
)?;
// A `host:path` operand runs over ssh, so ssh config can name another host.
// A `host::module` operand is the rsync daemon, which ssh config does not reach.
for spec in [&source, &destination] {
if !ops_is_remote(spec) {
continue;
}
let host = spec
.split(':')
.next()
.unwrap_or("")
.rsplit('@')
.next()
.unwrap_or("");
let egress_host = if spec.contains("::") {
host.to_string()
} else {
crate::safety::ssh_egress_host(B, host)?
};
crate::safety::check_egress(B, &[egress_host])?;
}
let mut argv = rsync_flags(B, args.get(2))?;
argv.push(source.clone());
argv.push(destination.clone());
let refs: Vec<&str> = argv.iter().map(String::as_str).collect();
let run = tool_run(
B,
"rsync",
None,
&refs,
Keep::Tail(TOOL_KEEP_TAIL),
TOOL_LONG_DEADLINE_SECS,
)?;
pkg_done(
B,
run,
vec![
("source", Value::Str(source)),
("destination", Value::Str(destination)),
],
)
}
// ============================================================================
// Security & Firewall Builtins (995-1014)
// Firewall, SELinux, AppArmor, auditd, fail2ban, OpenSSL, GPG
// ============================================================================
// [removed duplicate] use std::collections::BTreeMap;
// [removed duplicate] use std::process::Command;
// [removed duplicate] use anyhow::{anyhow, Result};
// Assuming Value is defined in the crate root or a value module
// [removed duplicate] use crate::value::Value;
// ---------------------------------------------------------------------------
// Helper: run a command, return stdout or error
// ---------------------------------------------------------------------------
/// Deadline for one security-tool query, such as gpg listing its keyring.
const SEC_RUN_DEADLINE_SECS: u64 = 60;
/// The most output one security-tool query may print. More is E_BUDGET_EXCEEDED.
const SEC_RUN_MAX_OUTPUT: usize = 16 * 1024 * 1024;
fn sec_run_cmd(program: &str, args: &[&str]) -> Result<String> {
sec_run_cmd_within(program, args, SEC_RUN_DEADLINE_SECS)
}
/// [`sec_run_cmd`] with its deadline given. Past it the tree is killed and the
/// call is a coded budget error. Output over the cap is refused the same way.
fn sec_run_cmd_within(program: &str, args: &[&str], deadline_secs: u64) -> Result<String> {
let output = run_output(
program,
program,
program,
args,
|_| {},
None,
deadline_secs,
SEC_RUN_MAX_OUTPUT,
)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(crate::safety::tool_failed(
program,
&format!("{} {}", program, args.join(" ")),
output.status.code(),
&stderr,
));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn sec_run_cmd_ok(program: &str, args: &[&str]) -> Option<String> {
sec_run_cmd(program, args).ok()
}
/// Test hook: [`sec_run_cmd`] with a deadline the test chooses, so a silent
/// child can be shown to be killed at it. Not part of the public API.
#[doc(hidden)]
pub fn __test_sec_run_cmd(program: &str, args: &[&str], deadline_secs: u64) -> Result<String> {
sec_run_cmd_within(program, args, deadline_secs)
}
fn cmd_exists(program: &str) -> bool {
// This used to find out whether a program existed by RUNNING it with
// `--version`, with no time limit, and then running `which` as well.
on_path(program)
}
fn rec(pairs: Vec<(&str, Value)>) -> Value {
let mut m = BTreeMap::new();
for (k, v) in pairs {
m.insert(k.to_string(), v);
}
Value::Record(m)
}
fn s(v: &str) -> Value {
Value::Str(v.to_string())
}
// ============================================================================
// 1010: bi_openssl_cert_info
// ============================================================================
// Reads a certificate's fields with `openssl x509 -text -noout`. A private key
// given here fails in openssl, and its content is not echoed.
fn bi_openssl_cert_info(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("openssl_cert_info", &input)?;
let file_path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"openssl_cert_info",
"file: String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
refuse_option("openssl_cert_info", &file_path)?;
let file_path = crate::safety::resolve_path_str(&file_path);
let mut cmd = crate::safety::child_command("openssl");
cmd.args(["x509", "-in", file_path.as_str(), "-text", "-noout"]);
let (out, _) = run_checked("openssl_cert_info", "openssl", cmd, LOCAL_DEADLINE_SECS)?;
let mut m = BTreeMap::new();
m.insert("file".to_string(), s(&file_path));
for line in out.lines() {
let trimmed = line.trim();
if trimmed.starts_with("Issuer:") {
m.insert(
"issuer".to_string(),
s(trimmed.strip_prefix("Issuer:").unwrap_or("").trim()),
);
} else if trimmed.starts_with("Subject:") {
m.insert(
"subject".to_string(),
s(trimmed.strip_prefix("Subject:").unwrap_or("").trim()),
);
} else if trimmed.starts_with("Not Before:") {
m.insert(
"not_before".to_string(),
s(trimmed.strip_prefix("Not Before:").unwrap_or("").trim()),
);
} else if trimmed.starts_with("Not After :") || trimmed.starts_with("Not After:") {
let val = trimmed
.strip_prefix("Not After :")
.or_else(|| trimmed.strip_prefix("Not After:"))
.unwrap_or("")
.trim();
m.insert("not_after".to_string(), s(val));
} else if trimmed.starts_with("Serial Number:") {
m.insert(
"serial_number".to_string(),
s(trimmed.strip_prefix("Serial Number:").unwrap_or("").trim()),
);
} else if trimmed.starts_with("Signature Algorithm:") {
m.insert(
"signature_algorithm".to_string(),
s(trimmed
.strip_prefix("Signature Algorithm:")
.unwrap_or("")
.trim()),
);
} else if trimmed.starts_with("Public Key Algorithm:") {
m.insert(
"public_key_algorithm".to_string(),
s(trimmed
.strip_prefix("Public Key Algorithm:")
.unwrap_or("")
.trim()),
);
}
}
m.insert("raw".to_string(), s(out.trim()));
Ok(Value::Record(m))
}
// ============================================================================
// 1011: bi_openssl_genrsa
// ============================================================================
// Generates an RSA private key into a new, unencrypted file (no passphrase).
// `bits` is 2048 to 16384, default 4096. An existing file is refused. A failed
// run removes the file it started. The key is never returned in the result.
fn bi_openssl_genrsa(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("openssl_genrsa", &input)?;
let file_path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"openssl_genrsa",
"file: String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let bits = match args.get(1) {
Some(Value::Int(n)) => *n,
None => 4096,
Some(other) => {
return Err(crate::safety::bad_arg(
"openssl_genrsa",
"bits: Int",
other.type_name(),
))
}
};
if !(2048..=16384).contains(&bits) {
return Err(crate::safety::bad_arg(
"openssl_genrsa",
"bits in 2048..=16384",
&bits.to_string(),
));
}
refuse_option("openssl_genrsa", &file_path)?;
let file_path = guard_local_write("openssl_genrsa", &file_path)?;
refuse_existing("openssl_genrsa", &file_path)?;
let bits_text = bits.to_string();
let mut cmd = crate::safety::child_command("openssl");
cmd.args(["genrsa", "-out", file_path.as_str(), bits_text.as_str()]);
let (raw, _) = match run_checked("openssl_genrsa", "openssl", cmd, LOCAL_DEADLINE_SECS) {
Ok(done) => done,
Err(e) => {
remove_partial(&file_path);
return Err(e);
}
};
Ok(rec(vec![
("action", s("genrsa")),
("file", s(&file_path)),
("bits", Value::Int(bits)),
("raw", s(raw.trim())),
]))
}
// ============================================================================
// 1012: bi_gpg_list_keys
// ============================================================================
fn bi_gpg_list_keys(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
// Takes no arguments and no piped input; both used to be silently dropped.
no_args_or_input("gpg_list_keys", &args, &input)?;
let out = sec_run_cmd("gpg", &["--list-keys", "--with-colons"])?;
let mut keys = Vec::new();
let mut current: Option<BTreeMap<String, Value>> = None;
for line in out.lines() {
let fields: Vec<&str> = line.split(':').collect();
if fields.is_empty() {
continue;
}
match fields[0] {
"pub" => {
// Save previous key if any
if let Some(k) = current.take() {
keys.push(Value::Record(k));
}
let mut m = BTreeMap::new();
m.insert("type".to_string(), s("pub"));
if fields.len() > 1 {
m.insert("validity".to_string(), s(fields[1]));
}
if fields.len() > 2 {
m.insert("key_length".to_string(), s(fields[2]));
}
if fields.len() > 3 {
m.insert("algorithm".to_string(), s(fields[3]));
}
if fields.len() > 4 {
m.insert("key_id".to_string(), s(fields[4]));
}
if fields.len() > 5 {
m.insert("creation_date".to_string(), s(fields[5]));
}
if fields.len() > 6 {
m.insert("expiration_date".to_string(), s(fields[6]));
}
current = Some(m);
}
"uid" => {
if let Some(ref mut m) = current {
if fields.len() > 9 {
m.insert("uid".to_string(), s(fields[9]));
}
}
}
"sub" => {
if let Some(ref mut m) = current {
let mut sub_info = String::new();
if fields.len() > 2 {
sub_info.push_str(fields[2]);
sub_info.push('/');
}
if fields.len() > 4 {
sub_info.push_str(fields[4]);
}
// Append to existing subkeys or create
let existing = m
.entry("subkeys".to_string())
.or_insert_with(|| Value::Array(Vec::new()));
if let Value::Array(ref mut arr) = existing {
arr.push(s(&sub_info));
}
}
}
"fpr" => {
if let Some(ref mut m) = current {
if fields.len() > 9 {
m.insert("fingerprint".to_string(), s(fields[9]));
}
}
}
_ => {}
}
}
// Push last key
if let Some(k) = current {
keys.push(Value::Record(k));
}
Ok(Value::Array(keys))
}
// ============================================================================
// 1013: bi_gpg_encrypt
// ============================================================================
// Encrypts a file to one recipient, into file.gpg. The output is refused if it
// exists. gpg runs with --batch and --no-tty, so it never waits at a prompt, and
// it writes the output file itself. The recipient is a key id, fingerprint or
// user id. No passphrase is ever taken on the command line.
fn bi_gpg_encrypt(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("gpg_encrypt", &input)?;
let file_path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"gpg_encrypt",
"file: String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let recipient = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"gpg_encrypt",
"recipient: String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
refuse_option("gpg_encrypt", &file_path)?;
refuse_option("gpg_encrypt", &recipient)?;
if recipient.trim().is_empty() || recipient.chars().any(char::is_control) {
return Err(crate::safety::bad_arg(
"gpg_encrypt",
"recipient: a key id, fingerprint or user id",
&recipient,
));
}
let input = crate::safety::resolve_path_str(&file_path);
regular_file_arg("gpg_encrypt", &input)?;
let output_file = guard_local_write("gpg_encrypt", &format!("{input}.gpg"))?;
refuse_existing("gpg_encrypt", &output_file)?;
let mut cmd = crate::safety::child_command("gpg");
cmd.args([
"--batch",
"--no-tty",
"--output",
output_file.as_str(),
"--encrypt",
"--recipient",
recipient.as_str(),
input.as_str(),
]);
let (raw, _) = match run_checked("gpg_encrypt", "gpg", cmd, LOCAL_DEADLINE_SECS) {
Ok(done) => done,
Err(e) => return Err(fail_removing_partial(e, &output_file)),
};
Ok(rec(vec![
("action", s("encrypt")),
("input_file", s(&input)),
("output_file", s(&output_file)),
("recipient", s(&recipient)),
("raw", s(raw.trim())),
]))
}
// Decrypts a .gpg or .asc file to the same name without that suffix, or to
// `<name>.decrypted` otherwise. The plaintext is written to the file and never
// returned. An existing output is refused. A passphrase, if the key needs one,
// comes from the agent's prompt, and the deadline stops the run if no one answers it.
fn bi_gpg_decrypt(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("gpg_decrypt", &input)?;
let file_path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"gpg_decrypt",
"file: String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
refuse_option("gpg_decrypt", &file_path)?;
let input = crate::safety::resolve_path_str(&file_path);
regular_file_arg("gpg_decrypt", &input)?;
let stem = input
.strip_suffix(".gpg")
.or_else(|| input.strip_suffix(".asc"))
.map(str::to_string)
.unwrap_or_else(|| format!("{input}.decrypted"));
let output_file = guard_local_write("gpg_decrypt", &stem)?;
refuse_existing("gpg_decrypt", &output_file)?;
let mut cmd = crate::safety::child_command("gpg");
cmd.args([
"--batch",
"--no-tty",
"--output",
output_file.as_str(),
"--decrypt",
input.as_str(),
]);
if let Err(e) = run_checked("gpg_decrypt", "gpg", cmd, LOCAL_DEADLINE_SECS) {
return Err(fail_removing_partial(e, &output_file));
}
Ok(rec(vec![
("action", s("decrypt")),
("input_file", s(&input)),
("output_file", s(&output_file)),
]))
}
// ---------------------------------------------------------------------------
// Monitor builtins: shared argument checks, sampling and the alert store.
// ---------------------------------------------------------------------------
/// The most rules the alert store keeps. A create past this is refused.
const MONITOR_MAX_RULES: usize = 1000;
/// The most history events the alert store keeps; the oldest are dropped.
const MONITOR_MAX_HISTORY: usize = 1000;
/// Gap between the CPU samples of `monitor_watch_cpu`.
const MONITOR_WATCH_INTERVAL_MS: u64 = 500;
const MONITOR_METRICS: &[&str] = &["cpu", "memory", "disk"];
const MONITOR_CONDITIONS: &[&str] = &[">", ">=", "<", "<="];
const MONITOR_SEVERITIES: &[&str] = &["info", "warning", "critical"];
/// Refuse pipeline input and more than `max` arguments: these builtins read the
/// machine, so input piped in would otherwise be dropped without a word.
fn monitor_direct_call(
builtin: &str,
args: &[Value],
input: &Option<Value>,
max: usize,
) -> Result<()> {
if input.is_some() {
return Err(crate::safety::bad_arg(
builtin,
"a direct call; pipeline input is not accepted",
"pipeline input",
));
}
if args.len() > max {
return Err(crate::safety::bad_arg(
builtin,
&format!("at most {max} argument(s)"),
&format!("{} arguments", args.len()),
));
}
Ok(())
}
/// How a rejected argument is described to the caller.
fn monitor_got(v: Option<&Value>) -> String {
match v {
None => "nothing".to_string(),
Some(Value::Str(s)) => format!("\"{}\"", s.chars().take(40).collect::<String>()),
Some(other) => other.type_name().to_string(),
}
}
/// An optional Int argument from `lo` to `hi`, or `default` when absent.
fn monitor_int_arg(
builtin: &str,
args: &[Value],
idx: usize,
default: i64,
lo: i64,
hi: i64,
what: &str,
) -> Result<i64> {
let expected = format!("{what} from {lo} to {hi}");
match args.get(idx) {
None => Ok(default),
Some(Value::Int(n)) if (lo..=hi).contains(n) => Ok(*n),
Some(Value::Int(n)) => Err(crate::safety::bad_arg(builtin, &expected, &n.to_string())),
Some(_) => Err(crate::safety::bad_arg(
builtin,
&expected,
&monitor_got(args.get(idx)),
)),
}
}
/// An optional percentage (Int or Float) from 0 to 100, or `default` when absent.
fn monitor_percent_arg(builtin: &str, args: &[Value], idx: usize, default: f64) -> Result<f64> {
let expected = "a finite percentage from 0 to 100";
match args.get(idx) {
None => Ok(default),
Some(Value::Int(n)) if (0..=100).contains(n) => Ok(*n as f64),
Some(Value::Float(f)) if f.is_finite() && (0.0..=100.0).contains(f) => Ok(*f),
Some(Value::Int(n)) => Err(crate::safety::bad_arg(builtin, expected, &n.to_string())),
Some(Value::Float(f)) => Err(crate::safety::bad_arg(builtin, expected, &f.to_string())),
Some(_) => Err(crate::safety::bad_arg(
builtin,
expected,
&monitor_got(args.get(idx)),
)),
}
}
/// An optional string argument that must be one of `choices`.
fn monitor_choice_arg(
builtin: &str,
args: &[Value],
idx: usize,
choices: &[&str],
default: &str,
) -> Result<String> {
match args.get(idx) {
None => Ok(default.to_string()),
Some(Value::Str(s)) if choices.contains(&s.as_str()) => Ok(s.clone()),
Some(_) => {
let listed = choices
.iter()
.map(|c| format!("\"{c}\""))
.collect::<Vec<_>>()
.join(", ");
Err(crate::safety::bad_arg(
builtin,
&format!("one of {listed}"),
&monitor_got(args.get(idx)),
))
}
}
}
/// An optional interface name. Absent is None; a given name must be a String
/// that is not option-like and is at most 256 bytes. On Unix it must also pass
/// the interface rule, because ip and ifconfig take it as a positional argument.
/// On Windows it is matched in Rust and never reaches a PowerShell script.
fn monitor_iface_arg(builtin: &str, args: &[Value], idx: usize) -> Result<Option<String>> {
match args.get(idx) {
None => Ok(None),
Some(Value::Str(name)) => {
crate::safety::reject_option_like(builtin, std::slice::from_ref(name))?;
if name.is_empty() || name.len() > 256 || name.chars().any(char::is_control) {
return Err(crate::safety::bad_arg(
builtin,
"an interface name of 1 to 256 characters",
&monitor_got(args.get(idx)),
));
}
#[cfg(not(target_os = "windows"))]
{
linux_diagnostics::interface_name(builtin, name)?;
}
Ok(Some(name.clone()))
}
Some(_) => Err(crate::safety::bad_arg(
builtin,
"an interface name String",
&monitor_got(args.get(idx)),
)),
}
}
/// A search keyword: 1 to 256 characters, no control character, and not
/// option-like, since journalctl and grep would read a leading `-` as a flag.
fn monitor_keyword(builtin: &str, keyword: &str) -> Result<()> {
crate::safety::reject_option_like(builtin, &[keyword.to_string()])?;
if keyword.is_empty() || keyword.len() > 256 || keyword.chars().any(char::is_control) {
return Err(crate::safety::bad_arg(
builtin,
"a keyword of 1 to 256 characters with no control character",
&format!("{:?}", keyword.chars().take(40).collect::<String>()),
));
}
Ok(())
}
/// The stdout of one bounded, read-only query. A nonzero exit is E_TOOL_FAILED
/// with the stderr tail; output past `cap` is E_BUDGET_EXCEEDED; a missing
/// program is E_TOOL_MISSING.
fn monitor_text(builtin: &str, program: &str, args: &[&str], cap: usize) -> Result<String> {
let run = tool_run(
builtin,
program,
None,
args,
Keep::Head(cap),
TOOL_DEADLINE_SECS,
)?;
tool_text(builtin, run)
}
/// The records of one bounded query that prints JSON.
fn monitor_json_records(builtin: &str, program: &str, args: &[&str]) -> Result<Value> {
let text = monitor_text(builtin, program, args, TOOL_MAX_JSON)?;
monitor_parse::json_array(builtin, program, &text)
}
/// The records of one bounded PowerShell query that prints JSON. A failure names
/// `label` rather than the script, which can be long.
fn monitor_ps_json(builtin: &str, label: &str, script: &str) -> Result<Value> {
let mut run = tool_run(
builtin,
"powershell",
None,
&["-NoProfile", "-Command", script],
Keep::Head(TOOL_MAX_JSON),
TOOL_DEADLINE_SECS,
)?;
run.command = label.to_string();
let text = tool_text(builtin, run)?;
monitor_parse::json_array(builtin, label, &text)
}
/// Keeps the records whose `field` contains `name`, ignoring case. An empty
/// result for a name the caller gave is E_NOT_FOUND, as `ip` answers.
#[cfg(target_os = "windows")]
fn monitor_named(builtin: &str, records: Value, name: Option<&str>, field: &str) -> Result<Value> {
let Some(name) = name else {
return Ok(records);
};
let want = name.to_lowercase();
let items = match records {
Value::Array(items) => items,
other => return Ok(other),
};
let kept: Vec<Value> = items
.into_iter()
.filter(|r| match r {
Value::Record(m) => {
matches!(m.get(field), Some(Value::Str(s)) if s.to_lowercase().contains(&want))
}
_ => false,
})
.collect();
if kept.is_empty() {
return Err(crate::safety::not_found(builtin, "interface", name));
}
Ok(Value::Array(kept))
}
/// Whether a failure is a program that is not installed (E_TOOL_MISSING).
#[cfg(target_os = "linux")]
fn monitor_tool_missing(err: &anyhow::Error) -> bool {
err.downcast_ref::<crate::safety::SafetyError>()
.is_some_and(|se| matches!(se.code, crate::safety::ErrorCode::ToolMissing))
}
/// The last `count` items of an array. Used where the tool cannot limit its own
/// output and answers oldest first.
#[cfg(target_os = "macos")]
fn monitor_newest(records: Value, count: i64) -> Value {
match records {
Value::Array(items) => {
let keep = count.max(0) as usize;
let start = items.len().saturating_sub(keep);
Value::Array(items.into_iter().skip(start).collect())
}
other => other,
}
}
/// A System with CPU usage. sysinfo reports 0% from a single refresh, because a
/// usage figure is the change between two refreshes.
fn monitor_sample_system() -> System {
let mut sys = System::new_all();
sys.refresh_all();
std::thread::sleep(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL);
sys.refresh_all();
sys
}
/// Percent of the summed disk space in use across all mounts. With no disks,
/// or zero total space, the old code returned 0.0% used, so a disk alert could
/// never fire. That is now a coded error.
fn monitor_disk_used_percent() -> Result<f64> {
let disks = Disks::new_with_refreshed_list();
let count = disks.iter().count();
let total: u64 = disks.iter().map(|d| d.total_space()).sum();
let avail: u64 = disks.iter().map(|d| d.available_space()).sum();
if count == 0 || total == 0 {
return Err(crate::safety::bad_state(
"monitor_alert_list",
&format!(
"disk usage unavailable ({} disks, {} bytes total)",
count, total
),
"platform_disk_usage",
));
}
Ok(total.saturating_sub(avail) as f64 / total as f64 * 100.0)
}
fn monitor_memory_used_percent(sys: &System) -> f64 {
let total = sys.total_memory();
if total > 0 {
sys.used_memory() as f64 / total as f64 * 100.0
} else {
0.0
}
}
/// The live reading for an alert metric; None for a metric it does not know.
fn monitor_metric_value(metric: &str, sys: &System, disk_pct: Option<f64>) -> Option<f64> {
match metric {
"cpu" => Some(sys.global_cpu_usage() as f64),
"memory" => Some(monitor_memory_used_percent(sys)),
"disk" => disk_pct,
_ => None,
}
}
fn monitor_breached(value: f64, condition: &str, threshold: f64) -> bool {
match condition {
">" => value > threshold,
">=" => value >= threshold,
"<" => value < threshold,
"<=" => value <= threshold,
_ => false,
}
}
/// The alert store: `alerts.json` in the monitor directory of the data
/// directory. `IRONSHELL_DATA_HOME` moves it, which is how tests keep it out of
/// the real data directory.
fn monitor_store_path() -> std::path::PathBuf {
crate::config::ShellConfig::data_dir()
.join("monitor")
.join("alerts.json")
}
#[derive(Default)]
struct MonitorStore {
rules: Vec<serde_json::Value>,
history: Vec<serde_json::Value>,
}
/// A store file that exists but does not parse. It is reported and left
/// alone: starting an empty store over it would destroy the user's rules.
fn monitor_store_error(builtin: &str, path: &std::path::Path, why: &str) -> anyhow::Error {
anyhow::Error::new(crate::safety::SafetyError {
code: crate::safety::ErrorCode::Io,
message: format!(
"{builtin}: the alert store {} cannot be read: {why}",
path.display()
),
builtin: builtin.to_string(),
hint: "move the file aside to start with an empty store; nothing was changed".to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: "an alert store written by monitor_alert_create".to_string(),
got: "a file that does not parse".to_string(),
})
}
fn monitor_store_load(builtin: &str) -> Result<MonitorStore> {
let path = monitor_store_path();
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(MonitorStore::default()),
Err(e) => return Err(crate::safety::fs_error(builtin, &path, &e)),
};
let doc: serde_json::Value = serde_json::from_str(&text)
.map_err(|e| monitor_store_error(builtin, &path, &e.to_string()))?;
let list = |key: &str| -> Result<Vec<serde_json::Value>> {
doc.get(key)
.and_then(|v| v.as_array())
.cloned()
.ok_or_else(|| monitor_store_error(builtin, &path, &format!("no \"{key}\" array")))
};
Ok(MonitorStore {
rules: list("rules")?,
history: list("history")?,
})
}
/// Write the store through a temporary file, so a crash leaves either the old
/// store or the new one.
fn monitor_store_save(builtin: &str, store: &MonitorStore) -> Result<()> {
let path = monitor_store_path();
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| crate::safety::fs_error(builtin, dir, &e))?;
}
let doc = serde_json::json!({ "rules": store.rules, "history": store.history });
let bytes = serde_json::to_vec_pretty(&doc)?;
let path_text = path.to_str().ok_or_else(|| {
crate::safety::bad_arg(builtin, "a UTF-8 store path", &path.display().to_string())
})?;
// A unique temporary name (not a shared "json.tmp") and cleanup on failure
// are both done by write_file_atomically.
write_file_atomically(builtin, path_text, &bytes)
}
fn monitor_history_push(store: &mut MonitorStore, event: &str, rule: &serde_json::Value) {
store.history.push(serde_json::json!({
"event": event,
"rule_id": rule.get("id").cloned().unwrap_or(serde_json::Value::Null),
"name": rule.get("name").cloned().unwrap_or(serde_json::Value::Null),
"at": chrono::Local::now().to_rfc3339(),
}));
let overflow = store.history.len().saturating_sub(MONITOR_MAX_HISTORY);
store.history.drain(..overflow);
}
fn monitor_rule_str<'a>(builtin: &str, rule: &'a serde_json::Value, key: &str) -> Result<&'a str> {
rule.get(key).and_then(|v| v.as_str()).ok_or_else(|| {
monitor_store_error(
builtin,
&monitor_store_path(),
&format!("a rule has no \"{key}\" text"),
)
})
}
fn monitor_rule_f64(builtin: &str, rule: &serde_json::Value, key: &str) -> Result<f64> {
rule.get(key).and_then(|v| v.as_f64()).ok_or_else(|| {
monitor_store_error(
builtin,
&monitor_store_path(),
&format!("a rule has no \"{key}\" number"),
)
})
}
// ============================================================================
// Monitoring & Diagnostics Builtins (1015-1034)
// System monitoring, network diagnostics, performance counters
// ============================================================================
// [removed duplicate] use std::collections::BTreeMap;
// [removed duplicate] use std::process::Command;
// [removed duplicate] use anyhow::anyhow;
// [removed duplicate] use crate::value::Value;
// [removed duplicate] use crate::builtins::json_to_value;
// ---------------------------------------------------------------------------
// 1015: bi_htop_snapshot — Process monitor snapshot (top 20 by memory)
// ---------------------------------------------------------------------------
// 1015: bi_htop_snapshot - Interactive-style process monitor (cross-platform via sysinfo)
// ---------------------------------------------------------------------------
pub fn bi_htop_snapshot(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "monitor_htop";
monitor_direct_call(B, &args, &input, 1)?;
let count = monitor_int_arg(B, &args, 0, 20, 1, 500, "a process count")? as usize;
let sys = monitor_sample_system();
let mut procs: Vec<_> = sys.processes().iter().collect();
procs.sort_by(|a, b| b.1.memory().cmp(&a.1.memory()));
let entries: Vec<Value> = procs
.iter()
.take(count)
.map(|(pid, p)| {
let mut rec = BTreeMap::new();
rec.insert("pid".to_string(), Value::Int(pid.as_u32() as i64));
rec.insert(
"name".to_string(),
Value::Str(p.name().to_string_lossy().to_string()),
);
rec.insert(
"cpu_percent".to_string(),
Value::Float(p.cpu_usage() as f64),
);
rec.insert("memory_bytes".to_string(), Value::Int(p.memory() as i64));
rec.insert(
"memory_mb".to_string(),
Value::Float(p.memory() as f64 / 1_048_576.0),
);
Value::Record(rec)
})
.collect();
Ok(Value::Array(entries))
}
// ---------------------------------------------------------------------------
// 1016: bi_iotop_snapshot — I/O monitor snapshot
// ---------------------------------------------------------------------------
// 1016: bi_iotop_snapshot - I/O monitor snapshot (cross-platform via sysinfo)
// ---------------------------------------------------------------------------
fn bi_iotop_snapshot(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "monitor_iotop";
monitor_direct_call(B, &args, &input, 1)?;
let count = monitor_int_arg(B, &args, 0, 20, 1, 500, "a process count")? as usize;
let sys = monitor_sample_system();
let mut procs: Vec<_> = sys.processes().iter().collect();
procs.sort_by(|a, b| {
let a_io = a.1.disk_usage().total_read_bytes + a.1.disk_usage().total_written_bytes;
let b_io = b.1.disk_usage().total_read_bytes + b.1.disk_usage().total_written_bytes;
b_io.cmp(&a_io)
});
let entries: Vec<Value> = procs
.iter()
.take(count)
.map(|(pid, p)| {
let du = p.disk_usage();
let mut rec = BTreeMap::new();
rec.insert("pid".to_string(), Value::Int(pid.as_u32() as i64));
rec.insert(
"name".to_string(),
Value::Str(p.name().to_string_lossy().to_string()),
);
rec.insert(
"total_read_bytes".to_string(),
Value::Int(du.total_read_bytes as i64),
);
rec.insert(
"total_written_bytes".to_string(),
Value::Int(du.total_written_bytes as i64),
);
rec.insert("read_bytes".to_string(), Value::Int(du.read_bytes as i64));
rec.insert(
"written_bytes".to_string(),
Value::Int(du.written_bytes as i64),
);
Value::Record(rec)
})
.collect();
Ok(Value::Array(entries))
}
// ---------------------------------------------------------------------------
// 1017: bi_nethogs_info — Network traffic per process
// ---------------------------------------------------------------------------
fn bi_nethogs_info(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "monitor_nethogs";
monitor_direct_call(B, &args, &input, 0)?;
#[cfg(target_os = "windows")]
{
let ps_cmd = "Get-NetTCPConnection | Where-Object { $_.State -eq 'Established' } | Select-Object OwningProcess, LocalAddress, LocalPort, RemoteAddress, RemotePort, State | ForEach-Object { $proc = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue; $_ | Add-Member -NotePropertyName ProcessName -NotePropertyValue $(if($proc){$proc.ProcessName}else{'unknown'}) -PassThru } | Select-Object -First 30 | ConvertTo-Json -Depth 3";
monitor_ps_json(B, "Get-NetTCPConnection", ps_cmd)
}
// nethogs is the only source on Unix (it needs root). The ss fallback this
// used to take answered records of another shape under the same name, so a
// missing or failing nethogs is now the coded error and nothing else.
#[cfg(not(target_os = "windows"))]
{
let text = monitor_text(B, "nethogs", &["-t", "-c", "1"], TOOL_MAX_TEXT)?;
Ok(Value::Array(monitor_parse::nethogs_trace(&text)?))
}
}
// ---------------------------------------------------------------------------
// 1018: bi_iftop_info — Network bandwidth info
// ---------------------------------------------------------------------------
fn bi_iftop_info(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "monitor_iftop";
monitor_direct_call(B, &args, &input, 0)?;
#[cfg(target_os = "windows")]
{
let text = monitor_text(B, "netstat", &["-s"], TOOL_MAX_TEXT)?;
let mut rec = BTreeMap::new();
rec.insert("source".to_string(), Value::Str("netstat -s".to_string()));
for (key, value) in monitor_parse::netstat_counters(&text)? {
rec.insert(key.to_string(), value);
}
rec.insert("raw_output".to_string(), Value::Str(text));
Ok(Value::Record(rec))
}
#[cfg(not(target_os = "windows"))]
{
// ss -s on Linux; netstat -s on macOS, which has no ss.
let tool = if cfg!(target_os = "macos") {
"netstat"
} else {
"ss"
};
let text = monitor_text(B, tool, &["-s"], TOOL_MAX_TEXT)?;
let mut rec = BTreeMap::new();
rec.insert("source".to_string(), Value::Str(format!("{tool} -s")));
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with("TCP:") {
rec.insert("tcp_summary".to_string(), Value::Str(trimmed.to_string()));
} else if trimmed.starts_with("UDP:") {
rec.insert("udp_summary".to_string(), Value::Str(trimmed.to_string()));
} else if trimmed.starts_with("Total:") || trimmed.starts_with("Transport") {
rec.insert("total_summary".to_string(), Value::Str(trimmed.to_string()));
}
}
rec.insert("raw_output".to_string(), Value::Str(text));
Ok(Value::Record(rec))
}
}
// ---------------------------------------------------------------------------
// 1017: bi_nmon_snapshot - System performance snapshot (cross-platform via sysinfo)
// ---------------------------------------------------------------------------
pub fn bi_nmon_snapshot(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
monitor_direct_call("monitor_nmon", &args, &input, 0)?;
let sys = monitor_sample_system();
let total_mem = sys.total_memory();
let used_mem = sys.used_memory();
let disks = Disks::new_with_refreshed_list();
let total_disk: u64 = disks.iter().map(|d| d.total_space()).sum();
let avail_disk: u64 = disks.iter().map(|d| d.available_space()).sum();
let mut rec = BTreeMap::new();
rec.insert("cpu_count".to_string(), Value::Int(sys.cpus().len() as i64));
rec.insert(
"cpu_percent".to_string(),
Value::Float(sys.global_cpu_usage() as f64),
);
load_fields(&mut rec);
rec.insert("memory_total".to_string(), Value::Int(total_mem as i64));
rec.insert("memory_used".to_string(), Value::Int(used_mem as i64));
rec.insert(
"memory_free".to_string(),
Value::Int(total_mem.saturating_sub(used_mem) as i64),
);
rec.insert("disk_total".to_string(), Value::Int(total_disk as i64));
rec.insert("disk_available".to_string(), Value::Int(avail_disk as i64));
rec.insert(
"timestamp".to_string(),
Value::Str(chrono::Local::now().to_rfc3339()),
);
Ok(Value::Record(rec))
}
// ---------------------------------------------------------------------------
// 1018: bi_glances_info - Comprehensive system info (cross-platform via sysinfo)
// ---------------------------------------------------------------------------
fn bi_glances_info(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
monitor_direct_call("monitor_glances", &args, &input, 0)?;
let sys = monitor_sample_system();
let total_mem = sys.total_memory();
let used_mem = sys.used_memory();
let total_swap = sys.total_swap();
let used_swap = sys.used_swap();
let cpus: Vec<Value> = sys
.cpus()
.iter()
.map(|c| {
let mut m = BTreeMap::new();
m.insert("name".to_string(), Value::Str(c.name().to_string()));
m.insert(
"usage_percent".to_string(),
Value::Float(c.cpu_usage() as f64),
);
m.insert(
"frequency_mhz".to_string(),
Value::Int(c.frequency() as i64),
);
Value::Record(m)
})
.collect();
let disks_list = Disks::new_with_refreshed_list();
let disk_entries: Vec<Value> = disks_list
.iter()
.map(|d| {
let mut m = BTreeMap::new();
m.insert(
"name".to_string(),
Value::Str(d.name().to_string_lossy().to_string()),
);
m.insert(
"mount".to_string(),
Value::Str(d.mount_point().to_string_lossy().to_string()),
);
m.insert(
"fs_type".to_string(),
Value::Str(d.file_system().to_string_lossy().to_string()),
);
m.insert(
"total_bytes".to_string(),
Value::Int(d.total_space() as i64),
);
m.insert(
"available_bytes".to_string(),
Value::Int(d.available_space() as i64),
);
let used = d.total_space().saturating_sub(d.available_space());
let pct = if d.total_space() > 0 {
used as f64 / d.total_space() as f64 * 100.0
} else {
0.0
};
m.insert("use_percent".to_string(), Value::Float(pct));
Value::Record(m)
})
.collect();
let nets = Networks::new_with_refreshed_list();
let net_entries: Vec<Value> = nets
.iter()
.map(|(name, data)| {
let mut m = BTreeMap::new();
m.insert("interface".to_string(), Value::Str(name.clone()));
m.insert(
"rx_bytes".to_string(),
Value::Int(data.total_received() as i64),
);
m.insert(
"tx_bytes".to_string(),
Value::Int(data.total_transmitted() as i64),
);
m.insert(
"rx_packets".to_string(),
Value::Int(data.total_packets_received() as i64),
);
m.insert(
"tx_packets".to_string(),
Value::Int(data.total_packets_transmitted() as i64),
);
Value::Record(m)
})
.collect();
let mut rec = BTreeMap::new();
// A missing host name was "", which read as a name. platform_snapshot
// gets it from sys_hostname, which refuses when it cannot be read.
rec.insert("hostname".to_string(), bi_sys_hostname(Vec::new(), None)?);
rec.insert(
"uptime_seconds".to_string(),
Value::Int(System::uptime() as i64),
);
rec.insert("cpu_count".to_string(), Value::Int(sys.cpus().len() as i64));
rec.insert("cpus".to_string(), Value::Array(cpus));
rec.insert("memory_total".to_string(), Value::Int(total_mem as i64));
rec.insert("memory_used".to_string(), Value::Int(used_mem as i64));
rec.insert(
"memory_percent".to_string(),
Value::Float(if total_mem > 0 {
used_mem as f64 / total_mem as f64 * 100.0
} else {
0.0
}),
);
rec.insert("swap_total".to_string(), Value::Int(total_swap as i64));
rec.insert("swap_used".to_string(), Value::Int(used_swap as i64));
rec.insert("disks".to_string(), Value::Array(disk_entries));
rec.insert("networks".to_string(), Value::Array(net_entries));
load_fields(&mut rec);
Ok(Value::Record(rec))
}
// chrono_timestamp helper - returns RFC3339 timestamp
fn chrono_timestamp() -> String {
chrono::Local::now().to_rfc3339()
}
// ---------------------------------------------------------------------------
// 1021: bi_tcpdump_capture — Packet capture
// ---------------------------------------------------------------------------
fn bi_tcpdump_capture(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "monitor_tcpdump";
linux_diagnostics::no_pipeline_input(B, &input)?;
if args.len() > 3 {
return Err(crate::safety::arg_err(
"monitor_tcpdump: expected at most (interface, count, seconds)",
));
}
let iface = match args.first() {
None => "any".to_string(),
Some(Value::Str(s)) => s.clone(),
Some(other) => {
return Err(crate::safety::bad_arg(
B,
"an interface name String",
other.type_name(),
))
}
};
let count = match args.get(1) {
None => 10,
Some(Value::Int(n)) if (1..=1000).contains(n) => *n,
Some(Value::Int(n)) => {
return Err(crate::safety::bad_arg(
B,
"a packet count from 1 to 1000",
&n.to_string(),
))
}
Some(other) => {
return Err(crate::safety::bad_arg(
B,
"a packet count Int",
other.type_name(),
))
}
};
let seconds = match args.get(2) {
None => 10,
Some(Value::Int(n)) if (1..=60).contains(n) => *n,
Some(Value::Int(n)) => {
return Err(crate::safety::bad_arg(
B,
"a duration in seconds from 1 to 60",
&n.to_string(),
))
}
Some(other) => {
return Err(crate::safety::bad_arg(
B,
"a duration Int in seconds",
other.type_name(),
))
}
};
linux_diagnostics::interface_name(B, &iface)?;
#[cfg(unix)]
{
// The deadline is the capture's duration: tcpdump stops at `count`
// packets or is killed (with its group) at `seconds`.
let limit = std::time::Duration::from_secs(seconds as u64);
let argv = vec![
"-l".to_string(),
"-nn".to_string(),
"-q".to_string(),
"-i".to_string(),
iface.clone(),
"-c".to_string(),
count.to_string(),
];
let run = linux_diagnostics::run_bounded(B, "tcpdump", "tcpdump", &argv, limit)?;
if run.truncated {
return Err(crate::safety::budget_exceeded(
B,
format!(
"tcpdump wrote more than {} bytes",
linux_diagnostics::MAX_OUTPUT
),
"use a smaller packet count",
));
}
if !run.timed_out && run.code != Some(0) {
return Err(crate::safety::tool_failed(
B,
"tcpdump",
run.code,
&run.stderr,
));
}
let packets: Vec<Value> = run
.stdout
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(|line| {
let mut rec = BTreeMap::new();
rec.insert("line".to_string(), Value::Str(line.to_string()));
Value::Record(rec)
})
.collect();
let mut result = BTreeMap::new();
result.insert("interface".to_string(), Value::Str(iface));
result.insert("count".to_string(), Value::Int(count));
result.insert("captured".to_string(), Value::Int(packets.len() as i64));
result.insert("timed_out".to_string(), Value::Bool(run.timed_out));
result.insert("packets".to_string(), Value::Array(packets));
Ok(Value::Record(result))
}
#[cfg(not(unix))]
{
let _ = (iface, count, seconds);
Err(crate::safety::unimplemented(
B,
"tcpdump_capture runs on Linux and macOS only; NOTHING WAS CAPTURED on this operating system",
"run it on Linux",
))
}
}
// ---------------------------------------------------------------------------
// 1022: bi_ss_info — Socket statistics
// ---------------------------------------------------------------------------
fn bi_ss_info(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "monitor_sockets";
monitor_direct_call(B, &args, &input, 1)?;
let filter = monitor_choice_arg(B, &args, 0, &["all", "tcp", "udp", "listening"], "all")?;
#[cfg(target_os = "windows")]
{
let text = monitor_text(B, "netstat", &["-an"], TOOL_MAX_TEXT)?;
Ok(Value::Array(monitor_parse::socket_records_windows(
&text, &filter,
)?))
}
#[cfg(not(target_os = "windows"))]
{
if cfg!(target_os = "macos") {
let text = monitor_text(B, "netstat", &["-an"], TOOL_MAX_TEXT)?;
Ok(Value::Array(monitor_parse::socket_records_bsd(
&text, &filter,
)?))
} else {
// One query for every TCP and UDP socket, with the filter applied to
// what it printed: "tcp" and "udp" include established sockets.
let text = monitor_text(B, "ss", &["-tuanH"], TOOL_MAX_TEXT)?;
Ok(Value::Array(monitor_parse::socket_records_ss(
&text, &filter,
)?))
}
}
}
// ---------------------------------------------------------------------------
// 1023: bi_ip_addr — IP address management
// ---------------------------------------------------------------------------
fn bi_ip_addr(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "monitor_ip_addr";
monitor_direct_call(B, &args, &input, 1)?;
let iface = monitor_iface_arg(B, &args, 0)?;
#[cfg(target_os = "windows")]
{
let ps_cmd = "Get-NetIPAddress | Select-Object InterfaceAlias, IPAddress, PrefixLength, AddressFamily, Type | ConvertTo-Json -Depth 3";
let records = monitor_ps_json(B, "Get-NetIPAddress", ps_cmd)?;
monitor_named(B, records, iface.as_deref(), "InterfaceAlias")
}
#[cfg(target_os = "linux")]
{
let mut argv = vec!["-j", "addr", "show"];
argv.extend(iface.as_deref());
monitor_json_records(B, "ip", &argv)
}
#[cfg(target_os = "macos")]
{
let mut argv: Vec<&str> = Vec::new();
argv.extend(iface.as_deref());
let text = monitor_text(B, "ifconfig", &argv, TOOL_MAX_TEXT)?;
let mut records = Vec::new();
let mut current_iface = String::new();
for line in text.lines() {
if !line.starts_with('\t') && !line.starts_with(' ') && line.contains(':') {
current_iface = line.split(':').next().unwrap_or("").to_string();
} else if line.trim().starts_with("inet ") {
let parts: Vec<&str> = line.trim().split_whitespace().collect();
if parts.len() >= 2 {
let mut rec = BTreeMap::new();
rec.insert("interface".to_string(), Value::Str(current_iface.clone()));
rec.insert("address".to_string(), Value::Str(parts[1].to_string()));
rec.insert("family".to_string(), Value::Str("inet".to_string()));
if let Some(pos) = parts.iter().position(|&x| x == "netmask") {
if parts.len() > pos + 1 {
rec.insert(
"netmask".to_string(),
Value::Str(parts[pos + 1].to_string()),
);
}
}
records.push(Value::Record(rec));
}
} else if line.trim().starts_with("inet6 ") {
let parts: Vec<&str> = line.trim().split_whitespace().collect();
if parts.len() >= 2 {
let mut rec = BTreeMap::new();
rec.insert("interface".to_string(), Value::Str(current_iface.clone()));
rec.insert("address".to_string(), Value::Str(parts[1].to_string()));
rec.insert("family".to_string(), Value::Str("inet6".to_string()));
records.push(Value::Record(rec));
}
}
}
Ok(Value::Array(records))
}
}
// ---------------------------------------------------------------------------
// 1024: bi_ip_route — IP route table
// ---------------------------------------------------------------------------
fn bi_ip_route(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "monitor_ip_route";
monitor_direct_call(B, &args, &input, 0)?;
#[cfg(target_os = "windows")]
{
let ps_cmd = "Get-NetRoute | Select-Object DestinationPrefix, NextHop, InterfaceAlias, RouteMetric, InterfaceMetric | ConvertTo-Json -Depth 3";
monitor_ps_json(B, "Get-NetRoute", ps_cmd)
}
#[cfg(target_os = "linux")]
{
monitor_json_records(B, "ip", &["-j", "route", "show"])
}
#[cfg(target_os = "macos")]
{
let text = monitor_text(B, "netstat", &["-rn"], TOOL_MAX_TEXT)?;
let mut records = Vec::new();
let mut header_seen = false;
for line in text.lines() {
if line.starts_with("Destination") {
header_seen = true;
continue;
}
if header_seen {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 4 {
let mut rec = BTreeMap::new();
rec.insert("destination".to_string(), Value::Str(parts[0].to_string()));
rec.insert("gateway".to_string(), Value::Str(parts[1].to_string()));
rec.insert("flags".to_string(), Value::Str(parts[2].to_string()));
rec.insert(
"interface".to_string(),
Value::Str(parts.last().unwrap_or(&"").to_string()),
);
records.push(Value::Record(rec));
}
}
}
Ok(Value::Array(records))
}
}
// ---------------------------------------------------------------------------
// 1025: bi_ip_link — Network link info
// ---------------------------------------------------------------------------
fn bi_ip_link(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "monitor_ip_link";
monitor_direct_call(B, &args, &input, 1)?;
let iface = monitor_iface_arg(B, &args, 0)?;
#[cfg(target_os = "windows")]
{
let ps_cmd = "Get-NetAdapter | Select-Object Name, InterfaceDescription, Status, MacAddress, LinkSpeed, MediaType | ConvertTo-Json -Depth 3";
let records = monitor_ps_json(B, "Get-NetAdapter", ps_cmd)?;
monitor_named(B, records, iface.as_deref(), "Name")
}
#[cfg(target_os = "linux")]
{
let mut argv = vec!["-j", "link", "show"];
argv.extend(iface.as_deref());
monitor_json_records(B, "ip", &argv)
}
#[cfg(target_os = "macos")]
{
let mut argv: Vec<&str> = Vec::new();
argv.extend(iface.as_deref());
let text = monitor_text(B, "ifconfig", &argv, TOOL_MAX_TEXT)?;
let mut records = Vec::new();
let mut current_iface = String::new();
let mut current_rec = BTreeMap::new();
for line in text.lines() {
if !line.starts_with('\t') && !line.starts_with(' ') && line.contains(':') {
if !current_iface.is_empty() {
current_rec.insert("interface".to_string(), Value::Str(current_iface.clone()));
records.push(Value::Record(current_rec.clone()));
}
current_iface = line.split(':').next().unwrap_or("").to_string();
current_rec = BTreeMap::new();
let flags_part = line.split('<').nth(1).and_then(|s| s.split('>').next());
if let Some(flags) = flags_part {
current_rec.insert("flags".to_string(), Value::Str(flags.to_string()));
}
} else if line.trim().starts_with("ether ") {
let parts: Vec<&str> = line.trim().split_whitespace().collect();
if parts.len() >= 2 {
current_rec.insert("mac_address".to_string(), Value::Str(parts[1].to_string()));
}
} else if line.trim().starts_with("status:") {
let parts: Vec<&str> = line.trim().split_whitespace().collect();
if parts.len() >= 2 {
current_rec.insert("status".to_string(), Value::Str(parts[1].to_string()));
}
}
}
if !current_iface.is_empty() {
current_rec.insert("interface".to_string(), Value::Str(current_iface));
records.push(Value::Record(current_rec));
}
Ok(Value::Array(records))
}
}
/// Windows: an adapter name, matched exactly (not as a -Name wildcard), so
/// `Local Area Connection* 1` names that adapter and no other. Letters,
/// digits, spaces and `- _ . ( ) # *` cover the names Windows gives adapters
/// ("Wi-Fi 4", "vEthernet (Default Switch)"); a `;` or `/` is refused, as
/// interface names are on Linux.
#[cfg(windows)]
fn ethtool_adapter(device: &str) -> Result<()> {
let plain = !device.trim().is_empty()
&& device.len() <= 256
&& !device.starts_with('-')
&& device
.chars()
.all(|c| c.is_alphanumeric() || " -_.()#*".contains(c));
if plain {
Ok(())
} else {
Err(crate::safety::bad_arg(
"monitor_ethtool",
"an adapter name such as \"Ethernet\" or \"Wi-Fi 4\"",
device,
))
}
}
/// Windows: one adapter's settings from Get-NetAdapter. The keys match the
/// ethtool record where the fact is the same: speed, duplex, link_detected. A
/// field the adapter does not report is left out.
#[cfg(windows)]
fn ethtool_windows(device: &str) -> Result<Value> {
const NAME: &str = "monitor_ethtool";
ethtool_adapter(device)?;
let script = crate::ps_script!(
"$n = {}; $a = Get-NetAdapter -ErrorAction Stop | Where-Object {{ $_.Name -eq $n }}; $a | Select-Object Name,LinkSpeed,FullDuplex,MediaConnectionState,DriverDescription,DriverVersion,MacAddress | ConvertTo-Json",
crate::safety::ps_quote(device)
);
let out = net_run(
NAME,
"powershell",
&["-NoProfile", "-NonInteractive", "-Command", script.as_str()],
NET_TOOL_SECS,
)?;
net_checked(NAME, "powershell", &out)?;
// An adapter that does not exist matches nothing and prints nothing.
let Some(item) = net_ps_items(NAME, &out.stdout)?.into_iter().next() else {
return Err(crate::safety::not_found(NAME, "network interface", device));
};
let Some(o) = item.as_object() else {
return Err(net_unparsed(NAME, "powershell"));
};
let text = |key: &str| {
o.get(key)
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| Value::Str(s.to_string()))
};
let mut rec = BTreeMap::new();
rec.insert("device".to_string(), Value::Str(device.to_string()));
for (out_key, json_key) in [
("speed", "LinkSpeed"),
("driver_description", "DriverDescription"),
("driver_version", "DriverVersion"),
("mac_address", "MacAddress"),
] {
if let Some(v) = text(json_key) {
rec.insert(out_key.to_string(), v);
}
}
match o.get("FullDuplex").and_then(|v| v.as_bool()) {
Some(true) => {
rec.insert("duplex".to_string(), Value::Str("Full".to_string()));
}
Some(false) => {
rec.insert("duplex".to_string(), Value::Str("Half".to_string()));
}
None => {}
}
// ConvertTo-Json writes the MediaConnectionState enum as a number: 1 is
// Connected, 2 is Disconnected, and 0 (Unknown) is left out.
match o.get("MediaConnectionState").and_then(|v| v.as_i64()) {
Some(1) => {
rec.insert("link_detected".to_string(), Value::Str("yes".to_string()));
}
Some(2) => {
rec.insert("link_detected".to_string(), Value::Str("no".to_string()));
}
_ => {}
}
Ok(Value::Record(rec))
}
/// macOS: one interface from `ifconfig <name>`, which changes nothing. mtu comes
/// from the header line, mac_address from the ether line, and link_detected is
/// yes or no from the status line (active or inactive). A line the interface does
/// not print is left out: loopback has neither a status nor an ether line. speed
/// and duplex are Null, since ifconfig does not report them.
#[cfg(target_os = "macos")]
fn ethtool_macos(device: &str) -> Result<Value> {
const NAME: &str = "monitor_ethtool";
let limit = std::time::Duration::from_secs(20);
let run =
linux_diagnostics::run_bounded(NAME, "ifconfig", "ifconfig", &[device.to_string()], limit)?;
if !run.timed_out
&& !run.truncated
&& run.code == Some(1)
&& run.stderr.contains("does not exist")
{
return Err(crate::safety::not_found(NAME, "network interface", device));
}
linux_diagnostics::require_success(NAME, "ifconfig", &run, limit)?;
let mut rec = BTreeMap::new();
rec.insert("device".to_string(), Value::Str(device.to_string()));
rec.insert("speed".to_string(), Value::Null);
rec.insert("duplex".to_string(), Value::Null);
for line in run.stdout.lines() {
if !line.starts_with(char::is_whitespace) {
// The header: `lo0: flags=8049<UP,LOOPBACK,RUNNING> mtu 16384`.
if let Some(i) = line.find(" mtu ") {
if let Some(mtu) = line[i + 5..].split_whitespace().next() {
rec.insert("mtu".to_string(), Value::Str(mtu.to_string()));
}
}
continue;
}
let t = line.trim();
if let Some(mac) = t.strip_prefix("ether ") {
if let Some(m) = mac.split_whitespace().next() {
rec.insert("mac_address".to_string(), Value::Str(m.to_string()));
}
} else if let Some(status) = t.strip_prefix("status: ") {
let link = match status.trim() {
"active" => Some("yes"),
"inactive" => Some("no"),
_ => None,
};
if let Some(link) = link {
rec.insert("link_detected".to_string(), Value::Str(link.to_string()));
}
}
}
Ok(Value::Record(rec))
}
// ---------------------------------------------------------------------------
// 1026: bi_ethtool_info — Ethernet device info (Linux and macOS; Windows reads Get-NetAdapter)
// ---------------------------------------------------------------------------
fn bi_ethtool_info(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
linux_diagnostics::no_pipeline_input("monitor_ethtool", &input)?;
let device = match args.as_slice() {
[Value::Str(s)] => s.clone(),
[other] => {
return Err(crate::safety::bad_arg(
"monitor_ethtool",
"a device name String such as \"eth0\"",
other.type_name(),
))
}
_ => {
return Err(crate::safety::arg_err(
"monitor_ethtool: expected one device name, e.g. \"eth0\"",
))
}
};
#[cfg(not(windows))]
linux_diagnostics::interface_name("monitor_ethtool", &device)?;
#[cfg(target_os = "linux")]
{
let text = monitor_text(
"monitor_ethtool",
"ethtool",
&[device.as_str()],
TOOL_MAX_TEXT,
)?;
let mut rec = BTreeMap::new();
rec.insert("device".to_string(), Value::Str(device.clone()));
for line in text.lines() {
if let Some((key, val)) = line.trim().split_once(':') {
let k = key.trim().to_lowercase().replace([' ', '-'], "_");
let v = val.trim();
if !k.is_empty() && !v.is_empty() {
rec.insert(k, Value::Str(v.to_string()));
}
}
}
Ok(Value::Record(rec))
}
#[cfg(windows)]
{
ethtool_windows(&device)
}
#[cfg(target_os = "macos")]
{
ethtool_macos(&device)
}
#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
{
let _ = device;
Err(crate::safety::unimplemented(
"monitor_ethtool",
"ethtool is Linux, macOS and Windows only; NOTHING WAS INSPECTED on this operating system",
"monitor_ip_link() lists interfaces on every OS",
))
}
}
// ---------------------------------------------------------------------------
// perf stat on Windows: the figures a finished child reports through its handle
// ---------------------------------------------------------------------------
/// What a finished child reports about itself: CPU times in 100 ns ticks, its
/// peak working set in bytes, and its page-fault count.
#[cfg(windows)]
struct WinProcStats {
user_ticks: u64,
kernel_ticks: u64,
peak_rss: u64,
page_faults: u32,
}
/// The Win32 PROCESS_MEMORY_COUNTERS record, laid out as K32GetProcessMemoryInfo
/// fills it. Only `peak_working_set_size` and `page_fault_count` are read.
#[cfg(windows)]
#[repr(C)]
#[derive(Default)]
#[allow(dead_code)]
struct WinProcessMemoryCounters {
cb: u32,
page_fault_count: u32,
peak_working_set_size: usize,
working_set_size: usize,
quota_peak_paged_pool_usage: usize,
quota_paged_pool_usage: usize,
quota_peak_non_paged_pool_usage: usize,
quota_non_paged_pool_usage: usize,
pagefile_usage: usize,
peak_pagefile_usage: usize,
}
/// Reads a child's CPU times, peak working set and page faults through its
/// process handle. The child must have exited or been killed, and its `Child`
/// must not be dropped yet: the handle is what keeps these figures readable.
#[cfg(windows)]
fn win_process_stats(child: &std::process::Child) -> std::io::Result<WinProcStats> {
use std::ffi::c_void;
use std::os::windows::io::AsRawHandle;
#[link(name = "kernel32")]
extern "system" {
fn GetProcessTimes(
process: *mut c_void,
creation: *mut WinFileTime,
exit: *mut WinFileTime,
kernel: *mut WinFileTime,
user: *mut WinFileTime,
) -> i32;
fn K32GetProcessMemoryInfo(
process: *mut c_void,
counters: *mut WinProcessMemoryCounters,
cb: u32,
) -> i32;
}
let handle = child.as_raw_handle();
let mut creation = WinFileTime::default();
let mut exit = WinFileTime::default();
let mut kernel = WinFileTime::default();
let mut user = WinFileTime::default();
// SAFETY: `handle` is the child's process handle, borrowed from `child`,
// which is alive for this call; each out-pointer is a local of the type the
// API writes.
if unsafe { GetProcessTimes(handle, &mut creation, &mut exit, &mut kernel, &mut user) } == 0 {
return Err(std::io::Error::last_os_error());
}
let cb = std::mem::size_of::<WinProcessMemoryCounters>() as u32;
let mut mem = WinProcessMemoryCounters {
cb,
..Default::default()
};
// SAFETY: as above; `mem` is a repr(C) record whose `cb` is its own size,
// which is what the API checks against.
if unsafe { K32GetProcessMemoryInfo(handle, &mut mem, cb) } == 0 {
return Err(std::io::Error::last_os_error());
}
Ok(WinProcStats {
user_ticks: user.ticks(),
kernel_ticks: kernel.ticks(),
peak_rss: mem.peak_working_set_size as u64,
page_faults: mem.page_fault_count,
})
}
// ---------------------------------------------------------------------------
// 1027: bi_perf_stat — Performance counters (Linux and Windows)
// ---------------------------------------------------------------------------
fn bi_perf_stat(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
linux_diagnostics::no_pipeline_input("perf_stat", &input)?;
if args.is_empty() || args.len() > 2 {
return Err(crate::safety::arg_err(
"perf_stat: expected an argv array and optionally an event list, e.g. perf_stat([\"/usr/bin/true\"], \"cycles\")",
));
}
let argv = linux_diagnostics::argv_arg("perf_stat", &args[0])?;
let events = match args.get(1) {
None => "cycles,instructions,cache-misses".to_string(),
Some(Value::Str(s)) => s.clone(),
Some(other) => {
return Err(crate::safety::bad_arg(
"perf_stat",
"an event list String such as \"cycles,instructions\"",
other.type_name(),
))
}
};
linux_diagnostics::event_list("perf_stat", &events)?;
let command = argv.join(" ");
crate::safety::guard_exec("perf_stat", command.clone())?;
#[cfg(target_os = "linux")]
{
let limit = std::time::Duration::from_secs(120);
let mut pargs = vec![
"stat".to_string(),
"-e".to_string(),
events.clone(),
"--".to_string(),
];
pargs.extend(argv.iter().cloned());
let run = linux_diagnostics::run_bounded("perf_stat", "perf", "perf", &pargs, limit)?;
linux_diagnostics::require_success("perf_stat", "perf stat", &run, limit)?;
// perf stat writes its report to stderr.
let mut counters = Vec::new();
for line in run.stderr.lines() {
let line = line.trim();
if line.is_empty()
|| line.starts_with('#')
|| line.starts_with("Performance counter stats")
{
continue;
}
// "value event # comment", or "<not supported> event".
let body = line.split('#').next().unwrap_or("").trim();
let (value, name) = if let Some(rest) = body.strip_prefix("<not") {
let (what, name) = rest.split_once('>').unwrap_or(("", rest));
(format!("<not{what}>"), name.trim().to_string())
} else {
match body.split_once(char::is_whitespace) {
Some((v, n)) => (v.to_string(), n.trim().to_string()),
None => continue,
}
};
if name.is_empty() {
continue;
}
let mut counter = BTreeMap::new();
counter.insert("value".to_string(), Value::Str(value));
counter.insert("name".to_string(), Value::Str(name));
counters.push(Value::Record(counter));
}
if counters.is_empty() {
return Err(crate::safety::tool_failed(
"perf_stat",
"perf stat",
run.code,
&format!("no counters were reported; stderr was: {}", run.stderr),
));
}
let mut rec = BTreeMap::new();
rec.insert("command".to_string(), Value::Str(command));
rec.insert("events".to_string(), Value::Str(events));
rec.insert("counters".to_string(), Value::Array(counters));
rec.insert("raw_output".to_string(), Value::Str(run.stderr));
Ok(Value::Record(rec))
}
#[cfg(windows)]
{
// The event list is Linux only. It was checked above; a call that passes
// one is refused here rather than read and ignored.
if args.len() == 2 {
return Err(crate::safety::unimplemented(
"perf_stat",
"the event list is Linux only: Windows reads a fixed set of counters; NOTHING WAS RUN",
"call monitor_perf_stat(argv) with the argv alone",
));
}
let _ = events;
// The argv runs as given: argv[0] is the program, found on PATH, with no
// shell and no perf in between.
let limit = std::time::Duration::from_secs(120);
let mut cmd = crate::safety::child_command(&argv[0]);
cmd.args(&argv[1..]);
let mut sampled = None;
let tree = run_tree_cmd_observed(
cmd,
None,
limit,
Keep::Head(linux_diagnostics::MAX_OUTPUT),
Keep::Head(linux_diagnostics::MAX_OUTPUT),
|child, waited| sampled = Some((win_process_stats(child), waited)),
)
.map_err(|e| match e {
TreeError::Spawn(e) => crate::safety::spawn_error("perf_stat", &argv[0], &e),
TreeError::Wait(e) => e.into(),
})?;
let run = linux_diagnostics::Run {
code: tree.code,
timed_out: tree.timed_out,
truncated: tree.stdout_truncated || tree.stderr_truncated || tree.pipes_abandoned,
stdout: String::from_utf8_lossy(&tree.stdout).into_owned(),
stderr: String::from_utf8_lossy(&tree.stderr).into_owned(),
};
linux_diagnostics::require_success("perf_stat", &command, &run, limit)?;
let (stats, waited) = match sampled {
Some((Ok(stats), waited)) => (stats, waited),
Some((Err(e), _)) => {
return Err(crate::safety::bad_state(
"perf_stat",
&format!(
"GetProcessTimes or K32GetProcessMemoryInfo failed on `{}`: {e}",
argv[0]
),
"a child this process started, read before its handle is closed",
))
}
None => {
return Err(crate::safety::bad_state(
"perf_stat",
"the child was not sampled before it was reaped",
"a child this process started and waited for",
))
}
};
let ms = |ticks: u64| Value::Int((ticks / 10_000) as i64);
let counter = |name: &str, value: Value| {
let mut c = BTreeMap::new();
c.insert("name".to_string(), Value::Str(name.to_string()));
c.insert("value".to_string(), value);
Value::Record(c)
};
let counters = vec![
counter("task_clock_ms", ms(stats.user_ticks + stats.kernel_ticks)),
counter("user_ms", ms(stats.user_ticks)),
counter("system_ms", ms(stats.kernel_ticks)),
counter("elapsed_ms", Value::Int(waited.as_millis() as i64)),
counter("peak_rss_bytes", Value::Int(stats.peak_rss as i64)),
counter("page_faults", Value::Int(i64::from(stats.page_faults))),
];
let mut rec = BTreeMap::new();
rec.insert("command".to_string(), Value::Str(command));
rec.insert("counters".to_string(), Value::Array(counters));
rec.insert("raw_output".to_string(), Value::Str(run.stderr));
Ok(Value::Record(rec))
}
#[cfg(not(any(target_os = "linux", windows)))]
{
let _ = (command, events);
Err(crate::safety::unimplemented(
"perf_stat",
"perf stat is Linux only; NOTHING WAS RUN on this operating system",
"run it on Linux",
))
}
}
// ---------------------------------------------------------------------------
// 1028: bi_perf_record — Performance recording info (Linux only)
// ---------------------------------------------------------------------------
fn bi_perf_record(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
linux_diagnostics::no_pipeline_input("perf_record", &input)?;
if args.is_empty() || args.len() > 2 {
return Err(crate::safety::arg_err(
"perf_record: expected an argv array and optionally an output path, e.g. perf_record([\"/usr/bin/true\"], \"/tmp/x.data\")",
));
}
let argv = linux_diagnostics::argv_arg("perf_record", &args[0])?;
let output_file = match args.get(1) {
None => "perf.data".to_string(),
Some(Value::Str(s)) => s.clone(),
Some(other) => {
return Err(crate::safety::bad_arg(
"perf_record",
"an output path String",
other.type_name(),
))
}
};
linux_diagnostics::output_path("perf_record", &output_file)?;
let command = argv.join(" ");
crate::safety::guard_exec("perf_record", command.clone())?;
#[cfg(target_os = "linux")]
{
let limit = std::time::Duration::from_secs(120);
let mut pargs = vec![
"record".to_string(),
"-o".to_string(),
output_file.clone(),
"--".to_string(),
];
pargs.extend(argv.iter().cloned());
let run = linux_diagnostics::run_bounded("perf_record", "perf", "perf", &pargs, limit)?;
linux_diagnostics::require_success("perf_record", "perf record", &run, limit)?;
let bytes = match std::fs::metadata(&output_file) {
Ok(meta) => meta.len(),
Err(_) => {
return Err(crate::safety::tool_failed(
"perf_record",
"perf record",
Some(0),
&format!("perf exited 0 but wrote no file at {output_file}"),
))
}
};
let mut rec = BTreeMap::new();
rec.insert("command".to_string(), Value::Str(command));
rec.insert("output_file".to_string(), Value::Str(output_file.clone()));
rec.insert("bytes".to_string(), Value::Int(bytes as i64));
rec.insert("success".to_string(), Value::Bool(true));
rec.insert(
"details".to_string(),
Value::Str(run.stderr.trim().to_string()),
);
rec.insert(
"hint".to_string(),
Value::Str(format!(
"Use 'perf report -i {output_file}' to analyze the recorded data"
)),
);
Ok(Value::Record(rec))
}
#[cfg(not(target_os = "linux"))]
{
let _ = (command, output_file);
Err(crate::safety::unimplemented(
"perf_record",
"perf record is Linux only; NOTHING WAS RUN on this operating system",
"run it on Linux",
))
}
}
// ---------------------------------------------------------------------------
// 1019: bi_free_mem - Memory usage (cross-platform via sysinfo)
// ---------------------------------------------------------------------------
pub fn bi_free_mem(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
monitor_direct_call("monitor_memory", &args, &input, 0)?;
let mut sys = System::new();
sys.refresh_memory();
let total = sys.total_memory();
let used = sys.used_memory();
let free = sys.free_memory();
let available = sys.available_memory();
let swap_total = sys.total_swap();
let swap_used = sys.used_swap();
let swap_free = swap_total.saturating_sub(swap_used);
let mut rec = BTreeMap::new();
rec.insert("total".to_string(), Value::Int(total as i64));
rec.insert("used".to_string(), Value::Int(used as i64));
rec.insert("free".to_string(), Value::Int(free as i64));
rec.insert("available".to_string(), Value::Int(available as i64));
rec.insert(
"use_percent".to_string(),
Value::Float(if total > 0 {
used as f64 / total as f64 * 100.0
} else {
0.0
}),
);
rec.insert("swap_total".to_string(), Value::Int(swap_total as i64));
rec.insert("swap_used".to_string(), Value::Int(swap_used as i64));
rec.insert("swap_free".to_string(), Value::Int(swap_free as i64));
Ok(Value::Record(rec))
}
// ---------------------------------------------------------------------------
// 1020: bi_uptime_extended - Extended uptime info (cross-platform via sysinfo)
// ---------------------------------------------------------------------------
fn bi_uptime_extended(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let _ = args;
let secs = System::uptime();
let days = secs / 86400;
let hours = (secs % 86400) / 3600;
let minutes = (secs % 3600) / 60;
let seconds = secs % 60;
let human = format!("{}d {}h {}m {}s", days, hours, minutes, seconds);
let mut rec = BTreeMap::new();
rec.insert("uptime_seconds".to_string(), Value::Int(secs as i64));
rec.insert("uptime_human".to_string(), Value::Str(human));
rec.insert("days".to_string(), Value::Int(days as i64));
rec.insert("hours".to_string(), Value::Int(hours as i64));
rec.insert("minutes".to_string(), Value::Int(minutes as i64));
rec.insert("seconds".to_string(), Value::Int(seconds as i64));
load_fields(&mut rec);
// A missing host name was "" (see bi_glances_info).
rec.insert("hostname".to_string(), bi_sys_hostname(Vec::new(), None)?);
Ok(Value::Record(rec))
}
// ---------------------------------------------------------------------------
// 1031: bi_who_users — Currently logged in users
// ---------------------------------------------------------------------------
fn bi_who_users(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "monitor_users";
monitor_direct_call(B, &args, &input, 0)?;
#[cfg(target_os = "windows")]
{
// query.exe exits 1 and says "No User exists" when nobody is logged on.
// That is an empty answer, not a failure.
let run = tool_run(
B,
"query",
None,
&["user"],
Keep::Head(TOOL_MAX_TEXT),
TOOL_DEADLINE_SECS,
)?;
if !run.success && format!("{}{}", run.stdout, run.stderr).contains("No User exists") {
return Ok(Value::Array(Vec::new()));
}
let text = tool_text(B, run)?;
Ok(Value::Array(monitor_parse::query_user_records(&text)?))
}
#[cfg(not(target_os = "windows"))]
{
let text = monitor_text(B, "who", &[], TOOL_MAX_TEXT)?;
Ok(Value::Array(monitor_parse::who_records(&text)?))
}
}
// ---------------------------------------------------------------------------
// 1032: bi_last_logins — Recent login history
// ---------------------------------------------------------------------------
fn bi_last_logins(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "monitor_logins";
monitor_direct_call(B, &args, &input, 1)?;
let count = monitor_int_arg(B, &args, 0, 20, 1, 1000, "a login count")?;
#[cfg(target_os = "windows")]
{
let ps_cmd = crate::ps_script!(
// Always an array (one event serialised as a bare object); no
// events is []; anything else -- access denied above all -- is an
// error, where all of it used to be {available: false, reason}.
r#"try {{
$r = @(Get-WinEvent -FilterHashtable @{{LogName='Security'; Id=4624}} -MaxEvents {} -ErrorAction Stop |
Select-Object TimeCreated, Id, @{{Name='User';Expression={{$_.Properties[5].Value}}}}, @{{Name='LogonType';Expression={{$_.Properties[8].Value}}}}, @{{Name='SourceIP';Expression={{$_.Properties[18].Value}}}})
ConvertTo-Json -InputObject $r -Depth 3
}} catch {{
if ($_.FullyQualifiedErrorId -like 'NoMatchingEventsFound*') {{ '[]' }}
else {{ [Console]::Error.WriteLine($_.Exception.Message); exit 1 }}
}}"#,
count
);
monitor_ps_json(
B,
"Get-WinEvent (the Security log needs an elevated shell)",
&ps_cmd,
)
}
#[cfg(not(target_os = "windows"))]
{
let n = count.to_string();
let text = monitor_text(B, "last", &["-n", n.as_str()], TOOL_MAX_TEXT)?;
Ok(Value::Array(monitor_parse::last_records(&text)?))
}
}
// ---------------------------------------------------------------------------
// 1033: bi_syslog_search — System log search
// ---------------------------------------------------------------------------
fn bi_syslog_search(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "monitor_syslog";
monitor_direct_call(B, &args, &input, 2)?;
let keyword = match args.first() {
Some(Value::Str(k)) => k.clone(),
_ => {
return Err(crate::safety::bad_arg(
B,
"a keyword String",
&monitor_got(args.first()),
))
}
};
monitor_keyword(B, &keyword)?;
let count = monitor_int_arg(B, &args, 1, 50, 1, 1000, "a match count")?;
#[cfg(target_os = "windows")]
{
let ps_cmd = crate::ps_script!(
// Always an array; no events is []; any other failure is an error,
// where it used to be {available: false, reason} at exit 0. Only the
// newest 500 System events are searched.
r#"try {{
$r = @(Get-WinEvent -FilterHashtable @{{LogName='System'}} -MaxEvents 500 -ErrorAction Stop |
Where-Object {{ $_.Message -match [regex]::Escape({}) }} |
Select-Object -First {} |
Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message)
ConvertTo-Json -InputObject $r -Depth 3
}} catch {{
if ($_.FullyQualifiedErrorId -like 'NoMatchingEventsFound*') {{ '[]' }}
else {{ [Console]::Error.WriteLine($_.Exception.Message); exit 1 }}
}}"#,
crate::safety::ps_quote(&keyword),
count
);
monitor_ps_json(B, "Get-WinEvent", &ps_cmd)
}
#[cfg(target_os = "linux")]
{
// journalctl first. The syslog file is read only when journalctl is not
// installed; a journalctl that runs and fails is an error, not a cue to
// answer from a different source.
let n = count.to_string();
let journal = tool_run(
B,
"journalctl",
None,
&[
"-g",
keyword.as_str(),
"-n",
n.as_str(),
"--no-pager",
"-o",
"json",
],
Keep::Head(TOOL_MAX_JSON),
TOOL_DEADLINE_SECS,
);
match journal {
Ok(run) => {
let text = tool_text(B, run)?;
Ok(Value::Array(monitor_parse::journal_records(&text)?))
}
Err(e) if monitor_tool_missing(&e) => {
let log = ["/var/log/syslog", "/var/log/messages"]
.into_iter()
.find(|p| std::path::Path::new(p).exists())
.ok_or_else(|| {
crate::safety::tool_missing(
B,
"journalctl",
"and there is no /var/log/syslog or /var/log/messages to read instead",
)
})?;
// grep: exit 1 is "no line matched"; `-e` keeps a keyword from
// being read as an option.
let run = tool_run(
B,
"grep",
None,
&["-i", "-e", keyword.as_str(), log],
Keep::Tail(TOOL_MAX_TEXT),
TOOL_DEADLINE_SECS,
)?;
if run.code == Some(1) {
return Ok(Value::Array(Vec::new()));
}
if !run.success {
return Err(crate::safety::tool_failed(B, "grep", run.code, &run.stderr));
}
// A tail cut short starts mid-line, so that fragment is dropped.
let text = if run.stdout_truncated {
run.stdout.split_once('\n').map_or("", |(_, rest)| rest)
} else {
run.stdout.as_str()
};
Ok(Value::Array(monitor_parse::grep_records(
text,
count as usize,
log,
)))
}
Err(e) => Err(e),
}
}
#[cfg(target_os = "macos")]
{
// Backslashes first: escaping only the quote let a trailing backslash
// re-open the string.
let predicate = format!(
"eventMessage contains \"{}\"",
keyword.replace('\\', "\\\\").replace('"', "\\\"")
);
let records = monitor_json_records(
B,
"log",
&[
"show",
"--predicate",
predicate.as_str(),
"--last",
"1h",
"--style",
"json",
],
)?;
Ok(monitor_newest(records, count))
}
}
// ---------------------------------------------------------------------------
// 1034: bi_dstat_info — System resource stats (aggregate)
// ---------------------------------------------------------------------------
// 1034: bi_dstat_info - System resource stats (cross-platform via sysinfo)
// ---------------------------------------------------------------------------
pub fn bi_dstat_info(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
monitor_direct_call("monitor_dstat", &args, &input, 0)?;
let sys = monitor_sample_system();
let mut stats = BTreeMap::new();
// Per-CPU usage
let cpus: Vec<Value> = sys
.cpus()
.iter()
.map(|c| {
let mut m = BTreeMap::new();
m.insert("name".to_string(), Value::Str(c.name().to_string()));
m.insert(
"usage_percent".to_string(),
Value::Float(c.cpu_usage() as f64),
);
Value::Record(m)
})
.collect();
stats.insert("cpus".to_string(), Value::Array(cpus));
stats.insert(
"cpu_percent".to_string(),
Value::Float(sys.global_cpu_usage() as f64),
);
// Memory
let total_mem = sys.total_memory();
let used_mem = sys.used_memory();
let mut mem = BTreeMap::new();
mem.insert("total".to_string(), Value::Int(total_mem as i64));
mem.insert("used".to_string(), Value::Int(used_mem as i64));
mem.insert(
"free".to_string(),
Value::Int(total_mem.saturating_sub(used_mem) as i64),
);
stats.insert("memory".to_string(), Value::Record(mem));
// Disks
let disks = Disks::new_with_refreshed_list();
let disk_entries: Vec<Value> = disks
.iter()
.map(|d| {
let mut m = BTreeMap::new();
m.insert(
"name".to_string(),
Value::Str(d.name().to_string_lossy().to_string()),
);
m.insert(
"total_bytes".to_string(),
Value::Int(d.total_space() as i64),
);
m.insert(
"available_bytes".to_string(),
Value::Int(d.available_space() as i64),
);
Value::Record(m)
})
.collect();
stats.insert("disk".to_string(), Value::Array(disk_entries));
// Networks
let nets = Networks::new_with_refreshed_list();
let net_entries: Vec<Value> = nets
.iter()
.map(|(name, data)| {
let mut m = BTreeMap::new();
m.insert("interface".to_string(), Value::Str(name.clone()));
m.insert(
"rx_bytes".to_string(),
Value::Int(data.total_received() as i64),
);
m.insert(
"tx_bytes".to_string(),
Value::Int(data.total_transmitted() as i64),
);
m.insert(
"rx_packets".to_string(),
Value::Int(data.total_packets_received() as i64),
);
m.insert(
"tx_packets".to_string(),
Value::Int(data.total_packets_transmitted() as i64),
);
Value::Record(m)
})
.collect();
stats.insert("network".to_string(), Value::Array(net_entries));
stats.insert(
"timestamp".to_string(),
Value::Str(chrono::Local::now().to_rfc3339()),
);
Ok(Value::Record(stats))
}
// ===========================================================================
// Proactive Monitoring & Alerting Builtins (836-843)
// ===========================================================================
// 836: bi_health_check - Comprehensive system health check
pub fn bi_health_check(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
monitor_direct_call("monitor_health_check", &args, &input, 0)?;
let sys = monitor_sample_system();
let cpu = sys.global_cpu_usage() as f64;
let total_mem = sys.total_memory();
let used_mem = sys.used_memory();
let mem_pct = if total_mem > 0 {
used_mem as f64 / total_mem as f64 * 100.0
} else {
0.0
};
let disks = Disks::new_with_refreshed_list();
let total_disk: u64 = disks.iter().map(|d| d.total_space()).sum();
let avail_disk: u64 = disks.iter().map(|d| d.available_space()).sum();
let disk_pct = if total_disk > 0 {
total_disk.saturating_sub(avail_disk) as f64 / total_disk as f64 * 100.0
} else {
0.0
};
// Determine overall health status
let status = if cpu > 95.0 || mem_pct > 95.0 || disk_pct > 95.0 {
"critical"
} else if cpu > 80.0 || mem_pct > 80.0 || disk_pct > 85.0 {
"warning"
} else {
"healthy"
};
let mut alerts = Vec::new();
if cpu > 80.0 {
let mut a = BTreeMap::new();
a.insert("type".to_string(), Value::Str("cpu".to_string()));
a.insert(
"severity".to_string(),
Value::Str(if cpu > 95.0 { "critical" } else { "warning" }.to_string()),
);
a.insert(
"message".to_string(),
Value::Str(format!("CPU usage at {:.1}%", cpu)),
);
a.insert("value".to_string(), Value::Float(cpu));
a.insert("threshold".to_string(), Value::Float(80.0));
alerts.push(Value::Record(a));
}
if mem_pct > 80.0 {
let mut a = BTreeMap::new();
a.insert("type".to_string(), Value::Str("memory".to_string()));
a.insert(
"severity".to_string(),
Value::Str(
if mem_pct > 95.0 {
"critical"
} else {
"warning"
}
.to_string(),
),
);
a.insert(
"message".to_string(),
Value::Str(format!("Memory usage at {:.1}%", mem_pct)),
);
a.insert("value".to_string(), Value::Float(mem_pct));
a.insert("threshold".to_string(), Value::Float(80.0));
alerts.push(Value::Record(a));
}
if disk_pct > 85.0 {
let mut a = BTreeMap::new();
a.insert("type".to_string(), Value::Str("disk".to_string()));
a.insert(
"severity".to_string(),
Value::Str(
if disk_pct > 95.0 {
"critical"
} else {
"warning"
}
.to_string(),
),
);
a.insert(
"message".to_string(),
Value::Str(format!("Disk usage at {:.1}%", disk_pct)),
);
a.insert("value".to_string(), Value::Float(disk_pct));
a.insert("threshold".to_string(), Value::Float(85.0));
alerts.push(Value::Record(a));
}
let mut rec = BTreeMap::new();
rec.insert("status".to_string(), Value::Str(status.to_string()));
rec.insert("cpu_percent".to_string(), Value::Float(cpu));
rec.insert("memory_percent".to_string(), Value::Float(mem_pct));
rec.insert("disk_percent".to_string(), Value::Float(disk_pct));
load_fields(&mut rec);
rec.insert(
"process_count".to_string(),
Value::Int(sys.processes().len() as i64),
);
rec.insert("alerts".to_string(), Value::Array(alerts));
rec.insert(
"timestamp".to_string(),
Value::Str(chrono::Local::now().to_rfc3339()),
);
Ok(Value::Record(rec))
}
// 837: bi_alert_create - Create an alert rule for a metric
pub fn bi_alert_create(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "monitor_alert_create";
monitor_direct_call(B, &args, &input, 5)?;
let name = match args.first() {
Some(Value::Str(s)) if !s.trim().is_empty() && s.chars().count() <= 100 => {
s.trim().to_string()
}
_ => {
return Err(crate::safety::bad_arg(
B,
"a rule name: a non-empty String of at most 100 characters",
&monitor_got(args.first()),
))
}
};
let metric = match args.get(1) {
Some(Value::Str(s)) if MONITOR_METRICS.contains(&s.as_str()) => s.clone(),
_ => {
return Err(crate::safety::bad_arg(
B,
"a metric: \"cpu\", \"memory\" or \"disk\"",
&monitor_got(args.get(1)),
))
}
};
let condition = monitor_choice_arg(B, &args, 2, MONITOR_CONDITIONS, ">")?;
let threshold = monitor_percent_arg(B, &args, 3, 80.0)?;
let severity = monitor_choice_arg(B, &args, 4, MONITOR_SEVERITIES, "warning")?;
let mut store = monitor_store_load(B)?;
if store.rules.len() >= MONITOR_MAX_RULES {
return Err(crate::safety::budget_exceeded(
B,
format!("the alert store already holds {MONITOR_MAX_RULES} rules"),
"remove a rule with monitor_alert_remove before creating another",
));
}
let base = format!(
"alert_{}_{}",
metric,
chrono::Local::now().timestamp_millis()
);
let mut id = base.clone();
let mut n = 1usize;
while store
.rules
.iter()
.any(|r| r.get("id").and_then(|v| v.as_str()) == Some(id.as_str()))
{
id = format!("{base}_{n}");
n += 1;
}
let rule = serde_json::json!({
"id": id,
"name": name,
"metric": metric,
"condition": condition,
"threshold": threshold,
"severity": severity,
"status": "active",
"created_at": chrono::Local::now().to_rfc3339(),
});
store.rules.push(rule.clone());
monitor_history_push(&mut store, "created", &rule);
monitor_store_save(B, &store)?;
Ok(json_to_value(rule))
}
// 838: bi_alert_list - List active alert rules
pub fn bi_alert_list(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "monitor_alert_list";
monitor_direct_call(B, &args, &input, 0)?;
let store = monitor_store_load(B)?;
if store.rules.is_empty() {
return Ok(Value::Array(Vec::new()));
}
let sys = monitor_sample_system();
// Read the disks only when a rule needs them, so a host whose disks
// cannot be measured still evaluates its cpu and memory rules.
let wants_disk = store
.rules
.iter()
.any(|r| r.get("metric").and_then(|m| m.as_str()) == Some("disk"));
let disk_pct = if wants_disk {
Some(monitor_disk_used_percent()?)
} else {
None
};
let mut out = Vec::with_capacity(store.rules.len());
for rule in &store.rules {
let metric = monitor_rule_str(B, rule, "metric")?;
let condition = monitor_rule_str(B, rule, "condition")?;
let threshold = monitor_rule_f64(B, rule, "threshold")?;
let current = monitor_metric_value(metric, &sys, disk_pct).ok_or_else(|| {
monitor_store_error(
B,
&monitor_store_path(),
&format!("a rule has the unknown metric \"{metric}\""),
)
})?;
let mut rec = rule.clone();
rec["current_value"] = serde_json::json!(current);
rec["triggered"] = serde_json::json!(monitor_breached(current, condition, threshold));
out.push(json_to_value(rec));
}
Ok(Value::Array(out))
}
// 839: bi_alert_history - Get alert history
pub fn bi_alert_history(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "monitor_alert_history";
monitor_direct_call(B, &args, &input, 1)?;
let limit = monitor_int_arg(B, &args, 0, 50, 1, 1000, "an event count")? as usize;
let store = monitor_store_load(B)?;
let out: Vec<Value> = store
.history
.iter()
.rev()
.take(limit)
.cloned()
.map(json_to_value)
.collect();
Ok(Value::Array(out))
}
// 840: bi_alert_remove - Remove an alert rule by ID
pub fn bi_alert_remove(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "monitor_alert_remove";
monitor_direct_call(B, &args, &input, 1)?;
let id = match args.first() {
Some(Value::Str(s)) if !s.is_empty() && s.len() <= 100 => s.clone(),
_ => {
return Err(crate::safety::bad_arg(
B,
"an alert id: a non-empty String of at most 100 bytes",
&monitor_got(args.first()),
))
}
};
let mut store = monitor_store_load(B)?;
let Some(pos) = store
.rules
.iter()
.position(|r| r.get("id").and_then(|v| v.as_str()) == Some(id.as_str()))
else {
return Err(crate::safety::not_found(B, "alert", &id));
};
let rule = store.rules.remove(pos);
monitor_history_push(&mut store, "removed", &rule);
monitor_store_save(B, &store)?;
Ok(json_to_value(serde_json::json!({
"id": id,
"removed": true,
"timestamp": chrono::Local::now().to_rfc3339(),
})))
}
// 841: bi_watch_cpu - Monitor CPU usage with threshold alerting
pub fn bi_watch_cpu(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "monitor_watch_cpu";
monitor_direct_call(B, &args, &input, 2)?;
let threshold = monitor_percent_arg(B, &args, 0, 80.0)?;
// At most 10 samples, 500 ms apart: a watch lasts at most about 5 seconds.
let samples = monitor_int_arg(B, &args, 1, 5, 1, 10, "a sample count")? as usize;
let mut sys = System::new();
sys.refresh_cpu_all();
let mut readings = Vec::with_capacity(samples);
let mut total = 0.0_f64;
let mut breaches = 0_usize;
for i in 0..samples {
std::thread::sleep(std::time::Duration::from_millis(MONITOR_WATCH_INTERVAL_MS));
sys.refresh_cpu_all();
let cpu = sys.global_cpu_usage() as f64;
let above = cpu > threshold;
total += cpu;
if above {
breaches += 1;
}
let mut sample = BTreeMap::new();
sample.insert("sample".to_string(), Value::Int(i as i64 + 1));
sample.insert("cpu_percent".to_string(), Value::Float(cpu));
sample.insert("above_threshold".to_string(), Value::Bool(above));
sample.insert(
"timestamp".to_string(),
Value::Str(chrono::Local::now().to_rfc3339()),
);
readings.push(Value::Record(sample));
}
let avg = total / samples as f64;
let mut rec = BTreeMap::new();
rec.insert("threshold".to_string(), Value::Float(threshold));
rec.insert("samples".to_string(), Value::Int(samples as i64));
rec.insert("average_cpu".to_string(), Value::Float(avg));
rec.insert("breaches".to_string(), Value::Int(breaches as i64));
rec.insert("alert".to_string(), Value::Bool(breaches > samples / 2));
rec.insert("readings".to_string(), Value::Array(readings));
Ok(Value::Record(rec))
}
// 842: bi_watch_memory - Monitor memory usage with threshold alerting
pub fn bi_watch_memory(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "monitor_watch_memory";
monitor_direct_call(B, &args, &input, 1)?;
let threshold = monitor_percent_arg(B, &args, 0, 80.0)?;
let mut sys = System::new();
sys.refresh_memory();
let total = sys.total_memory();
let used = sys.used_memory();
let free = sys.free_memory();
let available = sys.available_memory();
let pct = if total > 0 {
used as f64 / total as f64 * 100.0
} else {
0.0
};
let swap_total = sys.total_swap();
let swap_used = sys.used_swap();
let swap_pct = if swap_total > 0 {
swap_used as f64 / swap_total as f64 * 100.0
} else {
0.0
};
let status = if pct > 95.0 {
"critical"
} else if pct > threshold {
"warning"
} else {
"healthy"
};
let mut rec = BTreeMap::new();
rec.insert("status".to_string(), Value::Str(status.to_string()));
rec.insert("threshold".to_string(), Value::Float(threshold));
rec.insert("total_bytes".to_string(), Value::Int(total as i64));
rec.insert("used_bytes".to_string(), Value::Int(used as i64));
rec.insert("free_bytes".to_string(), Value::Int(free as i64));
rec.insert("available_bytes".to_string(), Value::Int(available as i64));
rec.insert("use_percent".to_string(), Value::Float(pct));
rec.insert("above_threshold".to_string(), Value::Bool(pct > threshold));
rec.insert("swap_total".to_string(), Value::Int(swap_total as i64));
rec.insert("swap_used".to_string(), Value::Int(swap_used as i64));
rec.insert("swap_percent".to_string(), Value::Float(swap_pct));
rec.insert(
"timestamp".to_string(),
Value::Str(chrono::Local::now().to_rfc3339()),
);
Ok(Value::Record(rec))
}
// 843: bi_watch_disk - Monitor disk usage with threshold alerting
pub fn bi_watch_disk(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "monitor_watch_disk";
monitor_direct_call(B, &args, &input, 1)?;
let threshold = monitor_percent_arg(B, &args, 0, 85.0)?;
let disks = Disks::new_with_refreshed_list();
let mut disk_entries = Vec::new();
let mut any_alert = false;
for disk in disks.iter() {
let total = disk.total_space();
let avail = disk.available_space();
let used = total.saturating_sub(avail);
let pct = if total > 0 {
used as f64 / total as f64 * 100.0
} else {
0.0
};
let above = pct > threshold;
if above {
any_alert = true;
}
let mut rec = BTreeMap::new();
rec.insert(
"mount".to_string(),
Value::Str(disk.mount_point().to_string_lossy().to_string()),
);
rec.insert(
"filesystem".to_string(),
Value::Str(disk.file_system().to_string_lossy().to_string()),
);
rec.insert("total_bytes".to_string(), Value::Int(total as i64));
rec.insert("used_bytes".to_string(), Value::Int(used as i64));
rec.insert("available_bytes".to_string(), Value::Int(avail as i64));
rec.insert("use_percent".to_string(), Value::Float(pct));
rec.insert("above_threshold".to_string(), Value::Bool(above));
let status = if pct > 95.0 {
"critical"
} else if above {
"warning"
} else {
"healthy"
};
rec.insert("status".to_string(), Value::Str(status.to_string()));
disk_entries.push(Value::Record(rec));
}
let mut rec = BTreeMap::new();
rec.insert("threshold".to_string(), Value::Float(threshold));
rec.insert("alert".to_string(), Value::Bool(any_alert));
rec.insert(
"disk_count".to_string(),
Value::Int(disk_entries.len() as i64),
);
rec.insert("disks".to_string(), Value::Array(disk_entries));
rec.insert(
"timestamp".to_string(),
Value::Str(chrono::Local::now().to_rfc3339()),
);
Ok(Value::Record(rec))
}
// ===================== Cloud Platform Builtins =====================
/// The current OS username (cross-platform). Neither variable set used to be
/// "unknown", which passed as a real user: workspace membership and package
/// authorship would record that name. It is refused instead.
///
/// Asked through `whoami`, which reads the user database by effective uid on
/// Unix: $USER is unset under cron, systemd units and many containers.
fn get_current_username(builtin: &str) -> Result<String> {
match bi_whoami(Vec::new(), None)? {
Value::Str(name) => Ok(name),
other => Err(crate::safety::bad_state(
builtin,
&format!("whoami answered {}", other.type_name()),
"whoami",
)),
}
}
/// 844 – cloud_deploy(name, [options]): not implemented. No cloud provider
/// client is wired in, so no deployment is created. The name and options are
/// checked first, so a malformed call is refused as an argument error.
fn bi_cloud_deploy(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "cloud_deploy";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 2)?;
k8s_need(B, "a deployment name", &args, 0, pkg_plain)?;
cloud_options(B, args.get(1), &["region", "instance_id", "script"])?;
Err(cloud_unimplemented(B))
}
/// List all cloud instances.
/// Usage: cloud.instances()
///
/// Not implemented. Nothing ever recorded an instance in this process, so the
/// old answer was always an empty list, which read as "no instances exist".
fn bi_cloud_instances(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "cloud_instances";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 0)?;
Err(cloud_unimplemented(B))
}
/// 846 – cloud_instance_create(name, [options]): not implemented, so no instance
/// is created. Checked first, as cloud_deploy is.
fn bi_cloud_instance_create(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "cloud_instance_create";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 2)?;
k8s_need(B, "an instance name", &args, 0, pkg_plain)?;
cloud_options(B, args.get(1), &["region", "type", "workspace"])?;
Err(cloud_unimplemented(B))
}
/// 847 – cloud_instance_destroy(id): approval-gated in agent mode, then not
/// implemented. The gate runs first, so the approval a person grants is never
/// answered by a silent no-op: the call stops with E_UNIMPLEMENTED and nothing
/// is destroyed.
fn bi_cloud_instance_destroy(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "cloud_instance_destroy";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 1)?;
let id = k8s_need(B, "an instance ID", &args, 0, pkg_plain)?;
crate::safety::guard(crate::safety::GuardCtx {
builtin: B,
effect: crate::safety::Effect::Destructive,
what: B,
targets: vec![id.clone()],
blast_radius: serde_json::json!({ "instance": id }),
reversible: false,
fs_paths: false,
})?;
Err(cloud_unimplemented(B))
}
/// 848 – cloud_instance_status(id): not implemented. It has no provider to ask.
fn bi_cloud_instance_status(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "cloud_instance_status";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 1)?;
k8s_need(B, "an instance ID", &args, 0, pkg_plain)?;
Err(cloud_unimplemented(B))
}
/// 849 – cloud_instance_connect(id): not implemented. No session is opened.
fn bi_cloud_instance_connect(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "cloud_instance_connect";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 1)?;
k8s_need(B, "an instance ID", &args, 0, pkg_plain)?;
Err(cloud_unimplemented(B))
}
/// List available cloud regions.
/// Usage: cloud.regions()
///
/// Not implemented. The old answer was a fixed AWS-style list that no provider
/// had supplied, so it was presented as this host's regions when it was not.
fn bi_cloud_regions(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "cloud_regions";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 0)?;
Err(cloud_unimplemented(B))
}
/// Get or set cloud platform configuration.
/// Usage: cloud.config() or cloud.config({provider?, region?, endpoint?, instance_type?})
fn bi_cloud_config(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
if let Some(Value::Record(opts)) = args.first() {
// Set config
let provider = opts
.get("provider")
.and_then(|v| {
if let Value::Str(s) = v {
Some(s.clone())
} else {
None
}
})
.unwrap_or_else(|| "ironshell".to_string());
let region = opts
.get("region")
.and_then(|v| {
if let Value::Str(s) = v {
Some(s.clone())
} else {
None
}
})
.unwrap_or_else(|| "us-east-1".to_string());
let endpoint = opts.get("endpoint").and_then(|v| {
if let Value::Str(s) = v {
Some(s.clone())
} else {
None
}
});
let instance_type = opts
.get("instance_type")
.and_then(|v| {
if let Value::Str(s) = v {
Some(s.clone())
} else {
None
}
})
.unwrap_or_else(|| "ae.small".to_string());
let config = CloudConfig {
provider: provider.clone(),
region: region.clone(),
api_endpoint: endpoint.clone(),
default_instance_type: instance_type.clone(),
};
let mut guard = CLOUD_CONFIG
.write()
.map_err(|_| crate::safety::lock_poisoned("cloud_config", "the cloud configuration"))?;
*guard = Some(config);
let mut rec = BTreeMap::new();
rec.insert("provider".to_string(), Value::Str(provider));
rec.insert("region".to_string(), Value::Str(region));
if let Some(ep) = endpoint {
rec.insert("endpoint".to_string(), Value::Str(ep));
}
rec.insert("instance_type".to_string(), Value::Str(instance_type));
rec.insert("status".to_string(), Value::Str("configured".to_string()));
Ok(Value::Record(rec))
} else {
// Get config
let guard = CLOUD_CONFIG
.read()
.map_err(|_| crate::safety::lock_poisoned("cloud_config", "the cloud configuration"))?;
if let Some(ref config) = *guard {
let mut rec = BTreeMap::new();
rec.insert("provider".to_string(), Value::Str(config.provider.clone()));
rec.insert("region".to_string(), Value::Str(config.region.clone()));
if let Some(ref ep) = config.api_endpoint {
rec.insert("endpoint".to_string(), Value::Str(ep.clone()));
}
rec.insert(
"instance_type".to_string(),
Value::Str(config.default_instance_type.clone()),
);
Ok(Value::Record(rec))
} else {
Ok(Value::Null)
}
}
}
// ===================== Remote REPL Builtins =====================
//
// repl_serve starts a loopback TCP listener that evaluates code sent by a peer
// holding the token it returns, and only in agent mode. repl_connect is one
// request per connection: it sends {token, code} and returns the value. Remote
// code reaches the same builtin dispatcher as local code, so `guard_dispatch`
// judges every call it makes; nothing on this path skips it.
//
// Wire format: one newline-terminated JSON object each way.
// request: {"token": "<token>", "code": "<source>"}
// response: {"ok": true, "value": <json>, "type": "<type name>"}
// {"ok": false, "error": "<message>"}
// A wrong token gets {"ok": false, "error": "unauthorized"} and the connection
// closes. repl_broadcast stays refused: no session is held open, so no peer set
// exists.
/// Longest request or response line, in bytes.
const REPL_MAX_LINE: usize = 1024 * 1024;
/// Connections served at once; one beyond this is refused with one error line.
const REPL_MAX_CONNECTIONS: usize = 8;
/// Wall-clock limit on one remote evaluation.
const REPL_EVAL_LIMIT: Duration = Duration::from_secs(30);
/// Read and write timeout on each socket operation.
const REPL_IO_TIMEOUT: Duration = Duration::from_secs(10);
/// The running listener, if any. One per process.
struct ReplServer {
addr: std::net::SocketAddr,
token: String,
stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
}
/// A request line that passed the shape check.
#[derive(Debug, PartialEq, Eq)]
struct ReplRequest {
token: String,
code: String,
}
/// Parse one request line. The error text is what the peer is told.
fn repl_parse_request(line: &[u8]) -> std::result::Result<ReplRequest, String> {
let text = std::str::from_utf8(line).map_err(|_| "request is not UTF-8".to_string())?;
let value: serde_json::Value =
serde_json::from_str(text.trim_end()).map_err(|e| format!("request is not JSON: {e}"))?;
let object = value
.as_object()
.ok_or_else(|| "request must be a JSON object".to_string())?;
let token = object
.get("token")
.and_then(|v| v.as_str())
.ok_or_else(|| "request needs a string token".to_string())?;
let code = object
.get("code")
.and_then(|v| v.as_str())
.ok_or_else(|| "request needs a string code".to_string())?;
Ok(ReplRequest {
token: token.to_string(),
code: code.to_string(),
})
}
/// Whether `given` is `expected`. Every byte of an equal-length pair is compared,
/// so a mismatch does not reveal how much of the token matched.
fn repl_token_matches(expected: &str, given: &str) -> bool {
let (a, b) = (expected.as_bytes(), given.as_bytes());
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
/// Read one newline-terminated line of at most `max` bytes, newline excluded.
/// `Ok(None)` is EOF before any byte. A longer line is an error. The reader is
/// never asked for more than one buffer past the cap, so a peer cannot grow the
/// line without bound.
fn repl_read_line<R: std::io::BufRead>(
reader: &mut R,
max: usize,
) -> std::result::Result<Option<Vec<u8>>, String> {
let mut line: Vec<u8> = Vec::new();
loop {
let (complete, used) = {
let buf = reader.fill_buf().map_err(|e| format!("read failed: {e}"))?;
if buf.is_empty() {
return if line.is_empty() {
Ok(None)
} else {
Err("connection closed mid-line".to_string())
};
}
match buf.iter().position(|&b| b == b'\n') {
Some(i) => {
line.extend_from_slice(&buf[..i]);
(true, i + 1)
}
None => {
line.extend_from_slice(buf);
(false, buf.len())
}
}
};
reader.consume(used);
if line.len() > max {
return Err(format!("line is longer than {max} bytes"));
}
if complete {
return Ok(Some(line));
}
}
}
/// Evaluate remote source as `ish -c` evaluates local source: parse, then
/// `eval_program` in the module environment. Each builtin the source calls is
/// dispatched, and so guarded, exactly as it is locally.
fn repl_eval(code: &str) -> std::result::Result<Value, String> {
let stmts = crate::parser::parse_program(code).map_err(|e| format!("parse error: {e}"))?;
let mut env = crate::modules::env_with_modules();
crate::eval::eval_program(&stmts, &mut env).map_err(|e| format!("{e}"))
}
fn repl_error_reply(reason: &str) -> serde_json::Value {
serde_json::json!({"ok": false, "error": reason})
}
/// Write one reply line. A peer that has gone away is not something the server
/// can act on, so the write result is dropped.
fn repl_send(stream: &mut std::net::TcpStream, reply: &serde_json::Value) {
use std::io::Write as _;
let mut line = reply.to_string();
line.push('\n');
let _ = stream.write_all(line.as_bytes());
}
/// Serve one connection: one request, one reply, then close.
fn repl_handle_connection(mut stream: std::net::TcpStream, token: &str) {
let _ = stream.set_read_timeout(Some(REPL_IO_TIMEOUT));
let _ = stream.set_write_timeout(Some(REPL_IO_TIMEOUT));
let read_side = match stream.try_clone() {
Ok(s) => s,
Err(_) => return,
};
let mut reader = std::io::BufReader::new(read_side);
let line = match repl_read_line(&mut reader, REPL_MAX_LINE) {
Ok(Some(line)) => line,
Ok(None) => return,
Err(reason) => {
repl_send(&mut stream, &repl_error_reply(&reason));
return;
}
};
let request = match repl_parse_request(&line) {
Ok(request) => request,
Err(reason) => {
repl_send(&mut stream, &repl_error_reply(&reason));
return;
}
};
if crate::safety::current_mode() != crate::safety::Mode::Agent {
repl_send(
&mut stream,
&repl_error_reply("remote evaluation needs agent mode"),
);
return;
}
if !repl_token_matches(token, &request.token) {
repl_send(&mut stream, &repl_error_reply("unauthorized"));
return;
}
let _deadline = crate::safety::enter_deadline(REPL_EVAL_LIMIT);
let reply = match repl_eval(&request.code) {
Ok(value) => {
let type_name = value.type_name().to_string();
serde_json::json!({
"ok": true,
"value": value_to_json(value),
"type": type_name,
})
}
Err(reason) => repl_error_reply(&reason),
};
repl_send(&mut stream, &reply);
}
/// Decrements the live-connection count however a connection thread ends.
struct ReplSlot(std::sync::Arc<std::sync::atomic::AtomicUsize>);
impl Drop for ReplSlot {
fn drop(&mut self) {
self.0.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
}
}
/// Accept connections until `stop` is set. The listener is non-blocking, so the
/// flag is seen within one poll interval.
fn repl_accept_loop(
listener: std::net::TcpListener,
token: String,
stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
) {
use std::sync::atomic::{AtomicUsize, Ordering};
let live = std::sync::Arc::new(AtomicUsize::new(0));
while !stop.load(Ordering::SeqCst) {
match listener.accept() {
Ok((mut stream, _peer)) => {
let _ = stream.set_nonblocking(false);
if live.fetch_add(1, Ordering::SeqCst) >= REPL_MAX_CONNECTIONS {
live.fetch_sub(1, Ordering::SeqCst);
repl_send(&mut stream, &repl_error_reply("too many connections"));
continue;
}
let slot = ReplSlot(live.clone());
let token = token.clone();
let _ = std::thread::Builder::new()
.name("repl-conn".to_string())
.spawn(move || {
let _slot = slot;
repl_handle_connection(stream, &token);
});
}
Err(_) => std::thread::sleep(Duration::from_millis(25)),
}
}
}
/// A transport failure on a REPL connection (`E_IO`).
fn repl_io_error(builtin: &str, message: &str) -> anyhow::Error {
anyhow::Error::new(crate::safety::SafetyError {
code: crate::safety::ErrorCode::Io,
builtin: builtin.into(),
message: format!("{builtin}: {message}"),
hint: "the connection was closed or stalled; no remote result was received".into(),
approval: None,
did_you_mean: vec![],
expected: "a complete reply line from the REPL listener".into(),
got: message.into(),
})
}
/// repl_serve(options?) - Serve this process's evaluation on a loopback port.
///
/// Options: `host` (a literal loopback address, default 127.0.0.1), `port`
/// (0..65535; 0 picks a free one), and `stop: true` to stop the running
/// listener. Returns {address, port, token, status}; every request must present
/// the token. Serving needs agent mode, so a peer gets the agent-mode policy.
fn bi_repl_serve(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "repl_serve";
refuse_piped_subject(NAME, &input)?;
if args.len() > 1 {
return Err(crate::safety::bad_arg(
NAME,
"at most one options Record",
"more arguments",
));
}
let fields = match args.into_iter().next() {
None | Some(Value::Null) => BTreeMap::new(),
Some(Value::Record(fields)) => fields,
Some(other) => {
return Err(crate::safety::bad_arg(
NAME,
"options: Record",
other.type_name(),
))
}
};
let mut host: std::net::IpAddr = std::net::Ipv4Addr::LOCALHOST.into();
let mut port: i64 = 0;
let mut stop = false;
for (key, value) in fields {
match (key.as_str(), value) {
("host", Value::Str(text)) => {
host = text.parse::<std::net::IpAddr>().map_err(|_| {
crate::safety::bad_arg(NAME, "host: a literal IP address", &text)
})?;
}
("port", Value::Int(n)) if (0..=65535i64).contains(&n) => port = n,
("stop", Value::Bool(flag)) => stop = flag,
(other, _) => {
return Err(crate::safety::bad_arg(
NAME,
"options: host String, port Int 0..65535, stop Bool",
other,
))
}
}
}
let mut slot = REPL_SERVER
.lock()
.map_err(|_| crate::safety::lock_poisoned(NAME, "the REPL listener"))?;
if stop {
let server = slot.take().ok_or_else(|| {
crate::safety::bad_state(NAME, "no REPL listener is running", "repl_serve")
})?;
server.stop.store(true, std::sync::atomic::Ordering::SeqCst);
let mut rec = BTreeMap::new();
rec.insert("address".to_string(), Value::Str(server.addr.to_string()));
rec.insert("status".to_string(), Value::Str("stopped".to_string()));
return Ok(Value::Record(rec));
}
if crate::tx::is_active() {
return Err(crate::safety::unimplemented(
NAME,
"listeners and network effects cannot be rolled back",
"finish the transaction first",
));
}
if !host.is_loopback() {
return Err(crate::safety::bad_arg(
NAME,
"host: a loopback address (127.0.0.1 or ::1)",
&host.to_string(),
));
}
if crate::safety::current_mode() != crate::safety::Mode::Agent {
return Err(crate::safety::policy_deny(
NAME,
"remote evaluation needs agent mode, so a peer cannot run code with human-mode permissions",
"set IRONSHELL_MODE=agent, then serve again",
));
}
let addr = std::net::SocketAddr::new(host, port as u16);
guard_network(NAME, &format!("tcp://{addr}"))?;
if let Some(running) = slot.as_ref() {
return Err(crate::safety::bad_state(
NAME,
&format!("already serving on {}", running.addr),
"repl_serve({stop: true})",
));
}
let listener = std::net::TcpListener::bind(addr).map_err(|e| nc_io_error(NAME, e))?;
let bound = listener.local_addr().map_err(|e| nc_io_error(NAME, e))?;
listener
.set_nonblocking(true)
.map_err(|e| nc_io_error(NAME, e))?;
// Two v4 UUIDs: 244 random bits, hex-encoded.
let token = format!(
"{}{}",
uuid::Uuid::new_v4().simple(),
uuid::Uuid::new_v4().simple()
);
let stop_flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let (thread_token, thread_stop) = (token.clone(), stop_flag.clone());
std::thread::Builder::new()
.name("repl-serve".to_string())
.spawn(move || repl_accept_loop(listener, thread_token, thread_stop))
.map_err(|e| nc_io_error(NAME, e))?;
let mut rec = BTreeMap::new();
rec.insert("address".to_string(), Value::Str(bound.to_string()));
rec.insert("port".to_string(), Value::Int(i64::from(bound.port())));
rec.insert("token".to_string(), Value::Str(token.clone()));
rec.insert("status".to_string(), Value::Str("listening".to_string()));
*slot = Some(ReplServer {
addr: bound,
token,
stop: stop_flag,
});
Ok(Value::Record(rec))
}
/// repl_connect(target, options) - Send one request to a repl_serve listener and
/// return its value. `target` is a `tcp://ip:port` String or a `{host, port}`
/// Record with a literal IP. `options` is `{token, code}`. A refused token raises
/// E_POLICY_DENY; any other failure from the remote side is returned as
/// {ok: false, error}.
fn bi_repl_connect(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "repl_connect";
refuse_piped_subject(NAME, &input)?;
let (target, options) = match <[Value; 2]>::try_from(args) {
Ok([target, options]) => (target, options),
Err(_) => {
return Err(crate::safety::bad_arg(
NAME,
"a target and an options Record {token, code}",
"a different number of arguments",
))
}
};
let target_hint = "target: a tcp://ip:port String or {host, port} Record";
let addr: std::net::SocketAddr = match &target {
Value::Str(text) => text
.strip_prefix("tcp://")
.unwrap_or(text.as_str())
.parse::<std::net::SocketAddr>()
.map_err(|_| crate::safety::bad_arg(NAME, target_hint, text))?,
Value::Record(fields) => {
let host = match fields.get("host") {
Some(Value::Str(h)) => h
.parse::<std::net::IpAddr>()
.map_err(|_| crate::safety::bad_arg(NAME, "host: a literal IP address", h))?,
_ => {
return Err(crate::safety::bad_arg(
NAME,
target_hint,
"a Record without a String host",
))
}
};
let port = match fields.get("port") {
Some(Value::Int(n)) if (1..=65535i64).contains(n) => *n as u16,
_ => {
return Err(crate::safety::bad_arg(
NAME,
"port: Int 1..65535",
"a missing or out-of-range port",
))
}
};
std::net::SocketAddr::new(host, port)
}
other => return Err(crate::safety::bad_arg(NAME, target_hint, other.type_name())),
};
let fields = match &options {
Value::Record(fields) => fields,
other => {
return Err(crate::safety::bad_arg(
NAME,
"options: Record {token, code}",
other.type_name(),
))
}
};
let token = match fields.get("token") {
Some(Value::Str(t)) if !t.is_empty() => t.clone(),
_ => {
return Err(crate::safety::bad_arg(
NAME,
"options.token: a non-empty String",
"a missing or non-String token",
))
}
};
let code = match fields.get("code") {
Some(Value::Str(c)) => c.clone(),
_ => {
return Err(crate::safety::bad_arg(
NAME,
"options.code: a String",
"a missing or non-String code",
))
}
};
let line = format!("{}\n", serde_json::json!({"token": token, "code": code}));
if line.len() > REPL_MAX_LINE {
return Err(crate::safety::bad_arg(
NAME,
"code that fits one 1 MiB request line",
"a longer String",
));
}
if crate::tx::is_active() {
return Err(crate::safety::unimplemented(
NAME,
"network effects cannot be rolled back",
"finish the transaction first",
));
}
guard_network(NAME, &format!("tcp://{addr}"))?;
let mut stream = std::net::TcpStream::connect_timeout(&addr, REPL_IO_TIMEOUT)
.map_err(|e| nc_io_error(NAME, e))?;
stream
.set_read_timeout(Some(REPL_EVAL_LIMIT + REPL_IO_TIMEOUT))
.map_err(|e| nc_io_error(NAME, e))?;
stream
.set_write_timeout(Some(REPL_IO_TIMEOUT))
.map_err(|e| nc_io_error(NAME, e))?;
std::io::Write::write_all(&mut stream, line.as_bytes()).map_err(|e| nc_io_error(NAME, e))?;
let read_side = stream.try_clone().map_err(|e| nc_io_error(NAME, e))?;
let mut reader = std::io::BufReader::new(read_side);
let reply_line = repl_read_line(&mut reader, REPL_MAX_LINE)
.map_err(|reason| repl_io_error(NAME, &reason))?
.ok_or_else(|| repl_io_error(NAME, "the listener closed without a reply"))?;
let reply: serde_json::Value = serde_json::from_slice(&reply_line)
.map_err(|e| repl_io_error(NAME, &format!("reply is not JSON: {e}")))?;
let mut rec = BTreeMap::new();
rec.insert("address".to_string(), Value::Str(addr.to_string()));
if reply.get("ok").and_then(|v| v.as_bool()) == Some(true) {
let value = json_to_value(
reply
.get("value")
.cloned()
.unwrap_or(serde_json::Value::Null),
);
let type_name = reply
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("Unknown")
.to_string();
rec.insert("ok".to_string(), Value::Bool(true));
rec.insert("value".to_string(), value);
rec.insert("type".to_string(), Value::Str(type_name));
return Ok(Value::Record(rec));
}
let reason = reply
.get("error")
.and_then(|v| v.as_str())
.unwrap_or("request refused")
.to_string();
if reason == "unauthorized" {
return Err(crate::safety::policy_deny(
NAME,
"the listener refused the token",
"present the token that repl_serve returned",
));
}
rec.insert("ok".to_string(), Value::Bool(false));
rec.insert("error".to_string(), Value::Str(reason));
Ok(Value::Record(rec))
}
/// repl_disconnect(session_id) - Remove a session from the session table.
fn bi_repl_disconnect(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped_subject("repl_disconnect", &input)?;
let id = collab_text_arg("repl_disconnect", "session_id", args.first(), 128)?;
let mut sessions = REPL_SESSIONS
.write()
.map_err(|_| crate::safety::lock_poisoned("repl_disconnect", "the REPL session table"))?;
let session = sessions
.remove(&id)
.ok_or_else(|| crate::safety::not_found("repl_disconnect", "repl session", &id))?;
let mut rec = BTreeMap::new();
rec.insert("id".to_string(), Value::Str(session.id));
rec.insert("status".to_string(), Value::Str("disconnected".to_string()));
rec.insert(
"disconnected_at".to_string(),
Value::Str(chrono::Local::now().to_rfc3339()),
);
Ok(Value::Record(rec))
}
/// repl_broadcast(expression) - Refused: there is no peer set to send to.
fn bi_repl_broadcast(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped_subject("repl_broadcast", &input)?;
collab_text_arg("repl_broadcast", "expression", args.first(), 4096)?;
Err(crate::safety::unimplemented(
"repl_broadcast",
"nothing is sent: repl_connect holds no session open, so there is no peer set to broadcast to",
"call repl_connect once per peer",
))
}
#[cfg(test)]
mod repl_protocol_tests {
use super::*;
use std::io::Cursor;
#[test]
fn request_line_yields_token_and_code() {
let parsed = repl_parse_request(br#"{"token":"t1","code":"1 + 1"}"#).expect("parses");
assert_eq!(
parsed,
ReplRequest {
token: "t1".into(),
code: "1 + 1".into(),
}
);
// A CRLF peer is tolerated.
let crlf = repl_parse_request(b"{\"token\":\"t\",\"code\":\"2\"}\r").expect("parses");
assert_eq!(crlf.code, "2");
}
#[test]
fn malformed_request_lines_are_refused_with_a_reason() {
let cases: [(&[u8], &str); 6] = [
(b"not json", "not JSON"),
(b"[1,2]", "JSON object"),
(br#"{"code":"1"}"#, "token"),
(br#"{"token":"t"}"#, "code"),
(br#"{"token":5,"code":"1"}"#, "token"),
(&[0xffu8, 0xfeu8], "UTF-8"),
];
for (line, why) in cases {
let reason = repl_parse_request(line).expect_err("refused");
assert!(reason.contains(why), "{reason} should mention {why}");
}
}
#[test]
fn token_check_requires_an_exact_match() {
assert!(repl_token_matches("abcd", "abcd"));
assert!(!repl_token_matches("abcd", "abce"));
assert!(!repl_token_matches("abcd", "abc"));
assert!(!repl_token_matches("abcd", "abcde"));
assert!(!repl_token_matches("abcd", ""));
}
#[test]
fn lines_are_split_on_newline_then_eof() {
let mut reader = Cursor::new(b"one\ntwo\n".to_vec());
assert_eq!(repl_read_line(&mut reader, 16), Ok(Some(b"one".to_vec())));
assert_eq!(repl_read_line(&mut reader, 16), Ok(Some(b"two".to_vec())));
assert_eq!(repl_read_line(&mut reader, 16), Ok(None));
}
#[test]
fn a_line_over_the_cap_is_refused_and_one_at_the_cap_is_kept() {
let mut over = vec![b'x'; 5000];
over.push(b'\n');
let mut reader = Cursor::new(over);
assert!(repl_read_line(&mut reader, 1000).is_err());
let mut exact = vec![b'x'; 1000];
exact.push(b'\n');
let mut reader = Cursor::new(exact);
assert_eq!(
repl_read_line(&mut reader, 1000).map(|l| l.map(|v| v.len())),
Ok(Some(1000))
);
}
#[test]
fn eof_mid_line_is_an_error_not_a_request() {
let mut reader = Cursor::new(b"partial".to_vec());
assert!(repl_read_line(&mut reader, 100).is_err());
}
}
// ===================== Team Workspace Builtins =====================
//
// A workspace is a membership list and a list of shared agent names, held in
// process memory. Membership is keyed by the USER or USERNAME environment
// variable, which the caller sets, so it names whoever set it and is not an
// access control. Nothing leaves the process: workspace_sync refuses.
/// Most workspaces one process holds.
const WORKSPACE_MAX: usize = 256;
/// Most members one workspace holds.
const WORKSPACE_MAX_MEMBERS: usize = 64;
/// Most agent names one workspace shares.
const WORKSPACE_MAX_AGENTS: usize = 64;
/// workspace_create(name, options?) - Create a workspace owned by the caller.
/// The only option is `description` (a String of at most 1024 bytes).
fn bi_workspace_create(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped_subject("workspace_create", &input)?;
let name = collab_text_arg("workspace_create", "name", args.first(), 128)?;
let mut description = String::new();
match args.get(1) {
None => {}
Some(Value::Record(opts)) => {
if let Some(key) = opts.keys().find(|k| k.as_str() != "description") {
return Err(crate::safety::bad_arg(
"workspace_create",
"options: {description?: String}",
&format!("unknown option `{key}`"),
));
}
match opts.get("description") {
None => {}
Some(Value::Str(s)) if s.len() <= 1024 => description = s.clone(),
Some(other) => {
return Err(crate::safety::bad_arg(
"workspace_create",
"description: a String of at most 1024 bytes",
&collab_got(other),
))
}
}
}
Some(other) => {
return Err(crate::safety::bad_arg(
"workspace_create",
"options: Record",
other.type_name(),
))
}
}
let id = uuid::Uuid::new_v4().to_string();
let owner = get_current_username("workspace_create")?;
let created_at = chrono::Local::now().to_rfc3339();
let mut workspaces = WORKSPACES
.write()
.map_err(|_| crate::safety::lock_poisoned("workspace_create", "the workspace registry"))?;
if workspaces.len() >= WORKSPACE_MAX {
return Err(crate::safety::budget_exceeded(
"workspace_create",
format!("workspace_create: {WORKSPACE_MAX} workspaces already exist in this process"),
"a process holds a bounded number of workspaces; start another process for more",
));
}
workspaces.insert(
id.clone(),
TeamWorkspace {
id: id.clone(),
name: name.clone(),
owner: owner.clone(),
members: vec![WorkspaceMember {
user: owner.clone(),
role: "owner".to_string(),
joined_at: created_at.clone(),
}],
shared_agents: Vec::new(),
created_at: created_at.clone(),
description: description.clone(),
},
);
let mut rec = BTreeMap::new();
rec.insert("id".to_string(), Value::Str(id));
rec.insert("name".to_string(), Value::Str(name));
rec.insert("owner".to_string(), Value::Str(owner));
rec.insert("description".to_string(), Value::Str(description));
rec.insert("member_count".to_string(), Value::Int(1));
rec.insert("created_at".to_string(), Value::Str(created_at));
Ok(Value::Record(rec))
}
/// workspace_join(workspace_id) - Add the caller to a workspace.
fn bi_workspace_join(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped_subject("workspace_join", &input)?;
let ws_id = collab_text_arg("workspace_join", "workspace_id", args.first(), 64)?;
let user = get_current_username("workspace_join")?;
let mut workspaces = WORKSPACES
.write()
.map_err(|_| crate::safety::lock_poisoned("workspace_join", "the workspace registry"))?;
let ws = workspaces
.get_mut(&ws_id)
.ok_or_else(|| crate::safety::not_found("workspace_join", "workspace", &ws_id))?;
if ws.members.iter().any(|m| m.user == user) {
return Err(crate::safety::bad_state(
"workspace_join",
&format!("already a member of '{}'", ws.name),
"workspace_members",
));
}
if ws.members.len() >= WORKSPACE_MAX_MEMBERS {
return Err(crate::safety::budget_exceeded(
"workspace_join",
format!(
"workspace_join: '{}' already has {WORKSPACE_MAX_MEMBERS} members",
ws.name
),
"a workspace holds a bounded number of members",
));
}
ws.members.push(WorkspaceMember {
user: user.clone(),
role: "member".to_string(),
joined_at: chrono::Local::now().to_rfc3339(),
});
let mut rec = BTreeMap::new();
rec.insert("workspace_id".to_string(), Value::Str(ws_id));
rec.insert("workspace_name".to_string(), Value::Str(ws.name.clone()));
rec.insert("user".to_string(), Value::Str(user));
rec.insert("role".to_string(), Value::Str("member".to_string()));
rec.insert("status".to_string(), Value::Str("joined".to_string()));
Ok(Value::Record(rec))
}
/// workspace_leave(workspace_id) - Remove the caller from a workspace. The
/// owner cannot leave: ownership cannot be transferred yet.
fn bi_workspace_leave(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped_subject("workspace_leave", &input)?;
let ws_id = collab_text_arg("workspace_leave", "workspace_id", args.first(), 64)?;
let user = get_current_username("workspace_leave")?;
let mut workspaces = WORKSPACES
.write()
.map_err(|_| crate::safety::lock_poisoned("workspace_leave", "the workspace registry"))?;
let ws = workspaces
.get_mut(&ws_id)
.ok_or_else(|| crate::safety::not_found("workspace_leave", "workspace", &ws_id))?;
if ws.owner == user {
return Err(crate::safety::policy_deny(
"workspace_leave",
format!("the owner of '{}' cannot leave it", ws.name),
"ownership cannot be transferred yet, so the owner stays",
));
}
let before = ws.members.len();
ws.members.retain(|m| m.user != user);
if ws.members.len() == before {
return Err(crate::safety::bad_state(
"workspace_leave",
&format!("not a member of '{}'", ws.name),
"workspace_members",
));
}
let mut rec = BTreeMap::new();
rec.insert("workspace_id".to_string(), Value::Str(ws_id));
rec.insert("workspace_name".to_string(), Value::Str(ws.name.clone()));
rec.insert("user".to_string(), Value::Str(user));
rec.insert("status".to_string(), Value::Str("left".to_string()));
Ok(Value::Record(rec))
}
/// workspace_members(workspace_id) - The members of a workspace.
fn bi_workspace_members(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped_subject("workspace_members", &input)?;
let ws_id = collab_text_arg("workspace_members", "workspace_id", args.first(), 64)?;
let workspaces = WORKSPACES
.read()
.map_err(|_| crate::safety::lock_poisoned("workspace_members", "the workspace registry"))?;
let ws = workspaces
.get(&ws_id)
.ok_or_else(|| crate::safety::not_found("workspace_members", "workspace", &ws_id))?;
let members: Vec<Value> = ws
.members
.iter()
.map(|m| {
let mut rec = BTreeMap::new();
rec.insert("user".to_string(), Value::Str(m.user.clone()));
rec.insert("role".to_string(), Value::Str(m.role.clone()));
rec.insert("joined_at".to_string(), Value::Str(m.joined_at.clone()));
Value::Record(rec)
})
.collect();
Ok(Value::Array(members))
}
/// workspace_share_agent(workspace_id, agent) - Share a registered agent (see
/// a2a_register) into a workspace the caller belongs to.
fn bi_workspace_share_agent(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped_subject("workspace_share_agent", &input)?;
let ws_id = collab_text_arg("workspace_share_agent", "workspace_id", args.first(), 64)?;
let agent = collab_text_arg("workspace_share_agent", "agent", args.get(1), 128)?;
let user = get_current_username("workspace_share_agent")?;
let mut workspaces = WORKSPACES.write().map_err(|_| {
crate::safety::lock_poisoned("workspace_share_agent", "the workspace registry")
})?;
let ws = workspaces
.get_mut(&ws_id)
.ok_or_else(|| crate::safety::not_found("workspace_share_agent", "workspace", &ws_id))?;
if !ws.members.iter().any(|m| m.user == user) {
return Err(crate::safety::policy_deny(
"workspace_share_agent",
format!("{user} is not a member of '{}'", ws.name),
"join the workspace before sharing an agent into it",
));
}
let registered = A2A_REGISTRY
.read()
.map_err(|_| {
crate::safety::lock_poisoned("workspace_share_agent", "the A2A agent registry")
})?
.contains_key(&agent);
if !registered {
return Err(crate::safety::not_found(
"workspace_share_agent",
"agent",
&agent,
));
}
if ws.shared_agents.contains(&agent) {
return Err(crate::safety::bad_state(
"workspace_share_agent",
&format!("'{agent}' is already shared in '{}'", ws.name),
"workspace_agents",
));
}
if ws.shared_agents.len() >= WORKSPACE_MAX_AGENTS {
return Err(crate::safety::budget_exceeded(
"workspace_share_agent",
format!(
"workspace_share_agent: '{}' already shares {WORKSPACE_MAX_AGENTS} agents",
ws.name
),
"a workspace shares a bounded number of agents",
));
}
ws.shared_agents.push(agent.clone());
let mut rec = BTreeMap::new();
rec.insert("workspace_id".to_string(), Value::Str(ws_id));
rec.insert("agent".to_string(), Value::Str(agent));
rec.insert("status".to_string(), Value::Str("shared".to_string()));
rec.insert(
"total_agents".to_string(),
Value::Int(ws.shared_agents.len() as i64),
);
Ok(Value::Record(rec))
}
/// workspace_agents(workspace_id) - The agent names shared in a workspace.
fn bi_workspace_agents(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped_subject("workspace_agents", &input)?;
let ws_id = collab_text_arg("workspace_agents", "workspace_id", args.first(), 64)?;
let workspaces = WORKSPACES
.read()
.map_err(|_| crate::safety::lock_poisoned("workspace_agents", "the workspace registry"))?;
let ws = workspaces
.get(&ws_id)
.ok_or_else(|| crate::safety::not_found("workspace_agents", "workspace", &ws_id))?;
Ok(Value::Array(
ws.shared_agents
.iter()
.map(|a| Value::Str(a.clone()))
.collect(),
))
}
/// workspace_sync(workspace_id) - Refused: there are no peers to sync with.
fn bi_workspace_sync(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped_subject("workspace_sync", &input)?;
let ws_id = collab_text_arg("workspace_sync", "workspace_id", args.first(), 64)?;
let workspaces = WORKSPACES
.read()
.map_err(|_| crate::safety::lock_poisoned("workspace_sync", "the workspace registry"))?;
if !workspaces.contains_key(&ws_id) {
return Err(crate::safety::not_found(
"workspace_sync",
"workspace",
&ws_id,
));
}
Err(crate::safety::unimplemented(
"workspace_sync",
"there are no remote peers, so nothing is synced; this workspace is local to this process",
"workspace_members and workspace_agents report its local state",
))
}
/// List active REPL sessions.
/// Usage: repl.sessions()
fn bi_repl_sessions(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let sessions = REPL_SESSIONS
.read()
.map_err(|_| crate::safety::lock_poisoned("repl_sessions", "the REPL session table"))?;
let entries: Vec<Value> = sessions
.values()
.map(|s| {
let mut rec = BTreeMap::new();
rec.insert("id".to_string(), Value::Str(s.id.clone()));
rec.insert("host".to_string(), Value::Str(s.host.clone()));
rec.insert("port".to_string(), Value::Int(s.port as i64));
rec.insert("user".to_string(), Value::Str(s.user.clone()));
rec.insert("status".to_string(), Value::Str(s.status.clone()));
rec.insert(
"connected_at".to_string(),
Value::Str(s.connected_at.clone()),
);
if let Some(ref ws) = s.workspace_id {
rec.insert("workspace_id".to_string(), Value::Str(ws.clone()));
}
Value::Record(rec)
})
.collect();
Ok(Value::Array(entries))
}
/// List all workspaces the current user belongs to.
/// Usage: workspace.list()
fn bi_workspace_list(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let workspaces = WORKSPACES
.read()
.map_err(|_| crate::safety::lock_poisoned("workspace_list", "the workspace registry"))?;
let user = get_current_username("workspace_list")?;
let entries: Vec<Value> = workspaces
.values()
.filter(|ws| ws.members.iter().any(|m| m.user == user))
.map(|ws| {
let mut rec = BTreeMap::new();
rec.insert("id".to_string(), Value::Str(ws.id.clone()));
rec.insert("name".to_string(), Value::Str(ws.name.clone()));
rec.insert("owner".to_string(), Value::Str(ws.owner.clone()));
rec.insert(
"description".to_string(),
Value::Str(ws.description.clone()),
);
rec.insert(
"member_count".to_string(),
Value::Int(ws.members.len() as i64),
);
rec.insert(
"agent_count".to_string(),
Value::Int(ws.shared_agents.len() as i64),
);
rec.insert("created_at".to_string(), Value::Str(ws.created_at.clone()));
Value::Record(rec)
})
.collect();
Ok(Value::Array(entries))
}
// ===================== Marketplace Builtins =====================
const MARKETPLACE_REGISTRY: &str = "https://packages.nervosys.ai";
/// A package name or version. Both are written into cache paths and registry
/// URLs, so anything that could leave the cache directory or read as an option
/// (a `/`, a `..`, a leading `-`) is refused here, before any request.
fn package_token(builtin: &str, what: &str, s: &str) -> Result<String> {
let ok = !s.is_empty()
&& s.len() <= 128
&& !s.starts_with(['-', '.'])
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '+'));
if ok {
Ok(s.to_string())
} else {
Err(crate::safety::bad_arg(
builtin,
&format!(
"{what}: 1 to 128 letters, digits, '.', '_', '-' or '+', not starting with '-' or '.'"
),
&format!("{s:?}"),
))
}
}
/// A string argument, refused with the expected shape when it is not one.
fn str_arg<'a>(builtin: &str, v: Option<&'a Value>, what: &str) -> Result<&'a str> {
match v {
Some(Value::Str(s)) => Ok(s.as_str()),
other => Err(crate::safety::bad_arg(
builtin,
what,
other.map_or("nothing", Value::type_name),
)),
}
}
/// A registry request that did not complete. Nothing local has changed when
/// this is returned, so the call can be repeated as it stands.
fn registry_failure(builtin: &str, e: &anyhow::Error) -> anyhow::Error {
// A coded error from the client (a refused path, a hostile archive, a
// missing token) keeps its code; it used to become E_POLICY_DENY here,
// which told the caller the shell's policy had refused when it had not.
if let Some(coded) = e.downcast_ref::<crate::safety::SafetyError>() {
let mut coded = coded.clone();
coded.builtin = builtin.to_string();
coded.message = format!("{builtin}: {}", coded.message);
return anyhow::Error::new(coded);
}
crate::safety::io_fault(
builtin,
&format!("the package registry did not complete the request: {e:#}"),
"the registry could not be reached or answered badly; check the network, then retry",
)
}
/// Whether a registry error says the package does not exist, as opposed to
/// the registry being unreachable or refusing the request.
fn registry_says_absent(e: &anyhow::Error) -> bool {
e.downcast_ref::<crate::safety::SafetyError>()
.is_some_and(|s| s.code == crate::safety::ErrorCode::NotFound)
}
/// Mark a record with where it came from, so a caller never mistakes a local
/// catalogue entry for the registry's answer.
fn with_source(mut v: Value, source: &str) -> Value {
if let Value::Record(r) = &mut v {
r.insert("source".to_string(), Value::Str(source.to_string()));
}
v
}
/// Publish a plugin or agent to the marketplace.
/// With `path`, uploads that file to packages.nervosys.ai (network-guarded,
/// inside the workspace, and needs IRONSHELL_REGISTRY_TOKEN); the status is then
/// `published`. Without `path` the package is recorded in the local catalogue
/// only, and the status says so: `recorded_locally`.
/// Usage: marketplace.publish({name, version, description?, category?, keywords?, path?})
fn bi_marketplace_publish(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
const FIELDS: &[&str] = &[
"name",
"version",
"description",
"category",
"keywords",
"path",
];
const MAX_KEYWORDS: usize = 32;
let opts = match args.first() {
Some(Value::Record(r)) => r.clone(),
_ => {
return Err(crate::safety::arg_err(
"marketplace.publish requires a Record with name, version, description",
))
}
};
if let Some(bad) = opts.keys().find(|k| !FIELDS.contains(&k.as_str())) {
return Err(crate::safety::bad_arg(
"marketplace_publish",
"a record with only the fields name, version, description, category, keywords and path",
&format!("field {bad:?}"),
));
}
// A field of the wrong type used to fall back to its default, so a number
// given as a description was published with an empty one.
let text = |key: &str| -> Result<Option<String>> {
match opts.get(key) {
None => Ok(None),
Some(Value::Str(s)) => Ok(Some(s.clone())),
Some(other) => Err(crate::safety::bad_arg(
"marketplace_publish",
&format!("{key}: a string"),
other.type_name(),
)),
}
};
let name = package_token(
"marketplace_publish",
"name",
&text("name")?
.ok_or_else(|| crate::safety::arg_err("marketplace.publish requires 'name'"))?,
)?;
let version = package_token(
"marketplace_publish",
"version",
&text("version")?
.ok_or_else(|| crate::safety::arg_err("marketplace.publish requires 'version'"))?,
)?;
let description = text("description")?.unwrap_or_default();
if description.len() > 4096 || description.chars().any(char::is_control) {
return Err(crate::safety::bad_arg(
"marketplace_publish",
"description: at most 4096 bytes with no control characters",
"a description that does not fit",
));
}
let category = text("category")?.unwrap_or_else(|| "general".to_string());
let keywords: Vec<String> = match opts.get("keywords") {
None => Vec::new(),
Some(Value::Array(arr)) if arr.len() <= MAX_KEYWORDS => arr
.iter()
.map(|v| match v {
Value::Str(s) => Ok(s.clone()),
other => Err(crate::safety::bad_arg(
"marketplace_publish",
"keywords: an array of strings",
other.type_name(),
)),
})
.collect::<Result<Vec<_>>>()?,
Some(other) => {
return Err(crate::safety::bad_arg(
"marketplace_publish",
&format!("keywords: an array of at most {MAX_KEYWORDS} strings"),
other.type_name(),
))
}
};
let path = match text("path")? {
None => None,
Some(p) => {
// The file is read and sent to a server, so only a file the
// workspace already holds may be published.
if !crate::safety::within_workspace(&p) {
return Err(crate::safety::outside_workspace("marketplace_publish", &p));
}
let meta = std::fs::metadata(&p)
.map_err(|e| crate::safety::fs_error("marketplace_publish", &p, &e))?;
if !meta.is_file() {
return Err(crate::safety::bad_arg(
"marketplace_publish",
"path: a file to upload",
&format!("{p:?}"),
));
}
Some(std::path::PathBuf::from(p))
}
};
// Egress is decided at dispatch: `Network` is centrally enforced, so the
// upload is metered and gated before this body runs.
// Resolved before the upload, so a missing user cannot leave a published
// package without an author.
let user = get_current_username("marketplace_publish")?;
let target = match &path {
Some(tarball) => {
let manifest = crate::packages::PackageManifest {
package: crate::packages::PackageInfo {
name: name.clone(),
version: version.clone(),
description: description.clone(),
authors: vec![user.clone()],
license: None,
repository: None,
main: "main.ish".to_string(),
},
dependencies: std::collections::HashMap::new(),
};
crate::packages::RegistryClient::default()
.publish(tarball, &manifest)
.map_err(|e| registry_failure("marketplace_publish", &e))?;
"registry"
}
None => "local",
};
let published_at = chrono::Local::now().to_rfc3339();
let package = MarketplacePackage {
name: name.clone(),
version: version.clone(),
author: user,
description: description.clone(),
category: category.clone(),
downloads: 0,
rating: 0.0,
rating_count: 0,
published_at: published_at.clone(),
installed: false,
keywords,
};
MARKETPLACE_PACKAGES
.write()
.map_err(|_| {
crate::safety::lock_poisoned("marketplace_publish", "the marketplace package cache")
})?
.insert(name.clone(), package);
let mut rec = BTreeMap::new();
rec.insert("name".to_string(), Value::Str(name));
rec.insert("version".to_string(), Value::Str(version));
rec.insert("description".to_string(), Value::Str(description));
rec.insert("category".to_string(), Value::Str(category));
rec.insert(
"status".to_string(),
Value::Str(
if target == "registry" {
"published"
} else {
"recorded_locally"
}
.to_string(),
),
);
rec.insert("target".to_string(), Value::Str(target.to_string()));
rec.insert("published_at".to_string(), Value::Str(published_at));
Ok(Value::Record(rec))
}
/// Search the marketplace for packages.
/// Searches the remote registry at packages.nervosys.ai, falls back to local cache.
/// Each record says whether it came from the `registry` or the `local_cache`. If
/// the registry could not be reached and the local cache has nothing to offer,
/// that is an error, not an empty list.
/// Usage: marketplace.search(query) or marketplace.search({q?, category?})
fn bi_marketplace_search(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
fn query_text(builtin: &str, s: &str) -> Result<String> {
if s.len() > 256 || s.chars().any(char::is_control) {
return Err(crate::safety::bad_arg(
builtin,
"a query of at most 256 bytes with no control characters",
&format!("{:?}", s.chars().take(40).collect::<String>()),
));
}
Ok(s.to_lowercase())
}
let (query, category) = match args.first() {
None => (String::new(), None),
Some(Value::Str(s)) => (query_text("marketplace_search", s)?, None),
Some(Value::Record(r)) => {
if let Some(bad) = r
.keys()
.find(|k| !matches!(k.as_str(), "q" | "query" | "category"))
{
return Err(crate::safety::bad_arg(
"marketplace_search",
"a record with only the fields q, query and category",
&format!("field {bad:?}"),
));
}
let q = match r.get("q").or_else(|| r.get("query")) {
None => String::new(),
Some(Value::Str(s)) => query_text("marketplace_search", s)?,
Some(other) => {
return Err(crate::safety::bad_arg(
"marketplace_search",
"q: a string",
other.type_name(),
))
}
};
let cat = match r.get("category") {
None => None,
Some(Value::Str(s)) => Some(s.clone()),
Some(other) => {
return Err(crate::safety::bad_arg(
"marketplace_search",
"category: a string",
other.type_name(),
))
}
};
(q, cat)
}
Some(other) => {
return Err(crate::safety::bad_arg(
"marketplace_search",
"a query string or a Record {q?, category?}",
other.type_name(),
))
}
};
guard_network("marketplace_search", MARKETPLACE_REGISTRY)?;
let client = crate::packages::RegistryClient::default();
let registry_error = match client.search(&query, category.as_deref()) {
Ok(remote_pkgs) if !remote_pkgs.is_empty() => {
let results: Vec<Value> = remote_pkgs
.iter()
.map(|p| {
with_source(
crate::packages::RegistryClient::package_to_value(p),
"registry",
)
})
.collect();
return Ok(Value::Array(results));
}
Ok(_) => None,
Err(e) => Some(e),
};
// Fall back to local cache
let packages = MARKETPLACE_PACKAGES.read().map_err(|_| {
crate::safety::lock_poisoned("marketplace_search", "the marketplace package cache")
})?;
let results: Vec<Value> = packages
.values()
.filter(|pkg| {
let matches_query = query.is_empty()
|| pkg.name.to_lowercase().contains(&query)
|| pkg.description.to_lowercase().contains(&query)
|| pkg
.keywords
.iter()
.any(|k| k.to_lowercase().contains(&query));
let matches_category = category
.as_ref()
.map(|c| pkg.category.to_lowercase() == c.to_lowercase())
.unwrap_or(true);
matches_query && matches_category
})
.map(marketplace_package_to_value)
.collect();
if let (true, Some(e)) = (results.is_empty(), registry_error.as_ref()) {
return Err(registry_failure("marketplace_search", e));
}
Ok(Value::Array(results))
}
fn marketplace_package_to_value(pkg: &MarketplacePackage) -> Value {
let mut rec = BTreeMap::new();
rec.insert("name".to_string(), Value::Str(pkg.name.clone()));
rec.insert("version".to_string(), Value::Str(pkg.version.clone()));
rec.insert("author".to_string(), Value::Str(pkg.author.clone()));
rec.insert(
"description".to_string(),
Value::Str(pkg.description.clone()),
);
rec.insert("category".to_string(), Value::Str(pkg.category.clone()));
rec.insert("downloads".to_string(), Value::Int(pkg.downloads));
rec.insert("rating".to_string(), Value::Float(pkg.rating));
rec.insert("rating_count".to_string(), Value::Int(pkg.rating_count));
rec.insert(
"published_at".to_string(),
Value::Str(pkg.published_at.clone()),
);
rec.insert("installed".to_string(), Value::Bool(pkg.installed));
rec.insert("source".to_string(), Value::Str("local_cache".to_string()));
if !pkg.keywords.is_empty() {
rec.insert(
"keywords".to_string(),
Value::Array(pkg.keywords.iter().map(|k| Value::Str(k.clone())).collect()),
);
}
Value::Record(rec)
}
/// Install a package from the marketplace: download the registry's archive
/// into the package cache and unpack it. Nothing is run; extraction is the
/// whole of it. A package the registry does not hold is an error. There is no
/// local fallback, because the local catalogue holds records, not archives.
/// Usage: marketplace.install(name) or marketplace.install(name, version)
fn bi_marketplace_install(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
crate::safety::check_egress_unknown(
"marketplace_install",
"the marketplace the package is fetched from is set by the configured registry, not the arguments",
)?;
let name = package_token(
"marketplace_install",
"name",
str_arg("marketplace_install", args.first(), "a package name string")?,
)?;
let version = match args.get(1) {
None => None,
Some(v) => Some(package_token(
"marketplace_install",
"version",
str_arg("marketplace_install", Some(v), "a version string")?,
)?),
};
// Not metered against IRONSHELL_MAX_NET: this builtin is gated as `Exec` at
// dispatch, and calling `guard_network` here would need it listed as
// self-guarding, which skips that approval. See the report.
let client = crate::packages::RegistryClient::default();
let pkg_info = match client.get_package(&name) {
Ok(p) => p,
Err(e) if registry_says_absent(&e) => {
return Err(crate::safety::not_found(
"marketplace_install",
"package",
&name,
))
}
Err(e) => return Err(registry_failure("marketplace_install", &e)),
};
// The registry's own version string names a directory, so it is checked
// like any other name before it is used.
let ver = match version {
Some(v) => v,
None => package_token("marketplace_install", "version", &pkg_info.version)?,
};
let install_path = client
.download_package(&name, &ver, pkg_info.checksum.as_deref())
.map_err(|e| registry_failure("marketplace_install", &e))?;
// Update the local catalogue. A poisoned lock used to skip this and still
// report "installed"; the download is on disk, so the error names it.
{
let mut packages = MARKETPLACE_PACKAGES.write().map_err(|_| {
crate::safety::lock_poisoned(
"marketplace_install",
&format!(
"the package catalogue (`{}` was downloaded but not recorded)",
install_path.display()
),
)
})?;
let existing = packages
.entry(name.clone())
.or_insert_with(|| MarketplacePackage {
name: name.clone(),
version: ver.clone(),
author: pkg_info.author.clone(),
description: pkg_info.description.clone(),
category: pkg_info
.category
.clone()
.unwrap_or_else(|| "general".to_string()),
downloads: pkg_info.downloads,
rating: 0.0,
rating_count: 0,
published_at: pkg_info.created_at.clone(),
installed: false,
keywords: pkg_info.keywords.clone(),
});
existing.installed = true;
existing.downloads += 1;
}
let mut rec = BTreeMap::new();
rec.insert("name".to_string(), Value::Str(name));
rec.insert("version".to_string(), Value::Str(ver));
rec.insert("status".to_string(), Value::Str("installed".to_string()));
rec.insert("source".to_string(), Value::Str("registry".to_string()));
rec.insert(
"path".to_string(),
Value::Str(install_path.display().to_string()),
);
rec.insert(
"installed_at".to_string(),
Value::Str(chrono::Local::now().to_rfc3339()),
);
Ok(Value::Record(rec))
}
/// Uninstall a marketplace package: remove its cached archive and unpacked
/// files, and clear the installed flag. Reports whether any files were there.
/// Usage: marketplace.uninstall(name)
fn bi_marketplace_uninstall(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let name = package_token(
"marketplace_uninstall",
"name",
str_arg(
"marketplace_uninstall",
args.first(),
"a package name string",
)?,
)?;
let mut packages = MARKETPLACE_PACKAGES.write().map_err(|_| {
crate::safety::lock_poisoned("marketplace_uninstall", "the marketplace package cache")
})?;
let Some(pkg) = packages.get_mut(&name) else {
return Err(crate::safety::not_found(
"marketplace_uninstall",
"package",
&name,
));
};
if !pkg.installed {
return Err(crate::safety::bad_state(
"marketplace_uninstall",
&format!("package '{name}' is not installed, so there is nothing to remove"),
"marketplace_install",
));
}
// The install wrote into the package's own cache directory. The name has
// been checked to be a single path segment, so this cannot reach beyond it.
let dir = crate::packages::RegistryClient::default()
.cache_dir
.join(&name);
let files_removed = if dir.exists() {
std::fs::remove_dir_all(&dir)
.map_err(|e| crate::safety::fs_error("marketplace_uninstall", &dir, &e))?;
true
} else {
false
};
pkg.installed = false;
let mut rec = BTreeMap::new();
rec.insert("name".to_string(), Value::Str(pkg.name.clone()));
rec.insert("status".to_string(), Value::Str("uninstalled".to_string()));
rec.insert("files_removed".to_string(), Value::Bool(files_removed));
rec.insert(
"uninstalled_at".to_string(),
Value::Str(chrono::Local::now().to_rfc3339()),
);
Ok(Value::Record(rec))
}
/// List installed marketplace packages.
/// Usage: marketplace.list()
fn bi_marketplace_list(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let packages = MARKETPLACE_PACKAGES.read().map_err(|_| {
crate::safety::lock_poisoned("marketplace_list", "the marketplace package cache")
})?;
let installed: Vec<Value> = packages
.values()
.filter(|pkg| pkg.installed)
.map(marketplace_package_to_value)
.collect();
Ok(Value::Array(installed))
}
/// Get detailed info about a marketplace package.
/// Checks remote registry first, falls back to local cache; the `source` field
/// says which answered. A registry that could not be reached, with nothing in
/// the local cache, is an error.
/// Usage: marketplace.info(name)
fn bi_marketplace_info(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let name = package_token(
"marketplace_info",
"name",
str_arg("marketplace_info", args.first(), "a package name string")?,
)?;
guard_network("marketplace_info", MARKETPLACE_REGISTRY)?;
let client = crate::packages::RegistryClient::default();
let registry_error = match client.get_package(&name) {
Ok(pkg) => {
return Ok(with_source(
crate::packages::RegistryClient::package_to_value(&pkg),
"registry",
))
}
Err(e) if registry_says_absent(&e) => None,
Err(e) => Some(e),
};
let packages = MARKETPLACE_PACKAGES.read().map_err(|_| {
crate::safety::lock_poisoned("marketplace_info", "the marketplace package cache")
})?;
if let Some(pkg) = packages.get(&name) {
return Ok(marketplace_package_to_value(pkg));
}
Err(match registry_error {
Some(e) => registry_failure("marketplace_info", &e),
None => crate::safety::not_found("marketplace_info", "package", &name),
})
}
/// Rate a marketplace package. The rating is kept in the local catalogue
/// (`scope: local_cache`); it is not sent to the registry.
/// Usage: marketplace.rate(name, score) — score 1-5
fn bi_marketplace_rate(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let name = package_token(
"marketplace_rate",
"name",
str_arg("marketplace_rate", args.first(), "a package name string")?,
)?;
let score = match args.get(1) {
Some(Value::Int(n)) => *n as f64,
Some(Value::Float(f)) => *f,
other => {
return Err(crate::safety::bad_arg(
"marketplace_rate",
"a score from 1 to 5",
other.map_or("nothing", Value::type_name),
))
}
};
if !(1.0..=5.0).contains(&score) {
return Err(crate::safety::bad_arg(
"marketplace_rate",
"a score from 1 to 5",
&format!("{score}"),
));
}
let mut packages = MARKETPLACE_PACKAGES.write().map_err(|_| {
crate::safety::lock_poisoned("marketplace_rate", "the marketplace package cache")
})?;
let Some(pkg) = packages.get_mut(&name) else {
return Err(crate::safety::not_found(
"marketplace_rate",
"package",
&name,
));
};
// Compute new weighted average
let total = pkg.rating * pkg.rating_count as f64 + score;
pkg.rating_count += 1;
pkg.rating = total / pkg.rating_count as f64;
let mut rec = BTreeMap::new();
rec.insert("name".to_string(), Value::Str(pkg.name.clone()));
rec.insert("rating".to_string(), Value::Float(pkg.rating));
rec.insert("rating_count".to_string(), Value::Int(pkg.rating_count));
rec.insert("your_rating".to_string(), Value::Float(score));
rec.insert("scope".to_string(), Value::Str("local_cache".to_string()));
Ok(Value::Record(rec))
}
/// Check installed marketplace packages against the registry for a newer
/// version. This reports `update_available` with the `latest` version; it does
/// not install anything. With a name, one package, and a registry failure is an
/// error. Without one, every installed package, and a package that could not be
/// checked says `unreachable` with the reason.
/// Usage: marketplace.update(name) or marketplace.update() for all
fn bi_marketplace_update(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let single = match args.first() {
None => None,
Some(v) => Some(package_token(
"marketplace_update",
"name",
str_arg("marketplace_update", Some(v), "a package name string")?,
)?),
};
// What to check, decided from the local catalogue before any request, so a
// name that is absent or not installed costs no network round trip.
let targets: Vec<(String, String)> = {
let packages = MARKETPLACE_PACKAGES.read().map_err(|_| {
crate::safety::lock_poisoned("marketplace_update", "the marketplace package cache")
})?;
match &single {
Some(name) => match packages.get(name) {
None => {
return Err(crate::safety::not_found(
"marketplace_update",
"package",
name,
))
}
Some(p) if !p.installed => {
return Err(crate::safety::bad_state(
"marketplace_update",
&format!("package '{name}' is not installed"),
"marketplace_install",
))
}
Some(p) => vec![(p.name.clone(), p.version.clone())],
},
None => packages
.values()
.filter(|p| p.installed)
.map(|p| (p.name.clone(), p.version.clone()))
.collect(),
}
};
let client = crate::packages::RegistryClient::default();
let mut out: Vec<Value> = Vec::with_capacity(targets.len());
for (name, local_version) in &targets {
guard_network("marketplace_update", MARKETPLACE_REGISTRY)?;
let mut rec = BTreeMap::new();
rec.insert("name".to_string(), Value::Str(name.clone()));
rec.insert("version".to_string(), Value::Str(local_version.clone()));
match client.get_package(name) {
Ok(remote) if remote.version == *local_version => {
rec.insert("status".to_string(), Value::Str("up_to_date".to_string()));
}
Ok(remote) => {
rec.insert("latest".to_string(), Value::Str(remote.version));
rec.insert(
"status".to_string(),
Value::Str("update_available".to_string()),
);
}
Err(e) if registry_says_absent(&e) => {
rec.insert(
"status".to_string(),
Value::Str("not_in_registry".to_string()),
);
}
Err(e) => {
if single.is_some() {
return Err(registry_failure("marketplace_update", &e));
}
rec.insert("status".to_string(), Value::Str("unreachable".to_string()));
rec.insert("reason".to_string(), Value::Str(format!("{e:#}")));
}
}
rec.insert(
"checked_at".to_string(),
Value::Str(chrono::Local::now().to_rfc3339()),
);
out.push(Value::Record(rec));
}
match single {
Some(_) => Ok(out.pop().unwrap_or(Value::Null)),
None => Ok(Value::Array(out)),
}
}
// ===================== Telemetry & Analytics Builtins =====================
/// Enable opt-in telemetry.
/// Usage: telemetry.enable()
fn bi_telemetry_enable(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut enabled = TELEMETRY_ENABLED
.write()
.map_err(|_| crate::safety::lock_poisoned("telemetry_enable", "the telemetry switch"))?;
*enabled = true;
// Record the opt-in event
let event = TelemetryEvent {
id: uuid::Uuid::new_v4().to_string(),
event_type: "telemetry_enabled".to_string(),
category: "system".to_string(),
data: HashMap::new(),
timestamp: chrono::Local::now().to_rfc3339(),
session_id: uuid::Uuid::new_v4().to_string(),
};
if let Ok(mut events) = TELEMETRY_EVENTS.write() {
events.push(event);
}
let mut rec = BTreeMap::new();
rec.insert("enabled".to_string(), Value::Bool(true));
rec.insert(
"status".to_string(),
Value::Str("telemetry enabled (opt-in)".to_string()),
);
rec.insert(
"note".to_string(),
Value::Str("Data is stored locally. Use telemetry.report() to view.".to_string()),
);
Ok(Value::Record(rec))
}
/// Disable telemetry.
/// Usage: telemetry.disable()
fn bi_telemetry_disable(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut enabled = TELEMETRY_ENABLED
.write()
.map_err(|_| crate::safety::lock_poisoned("telemetry_disable", "the telemetry switch"))?;
*enabled = false;
let mut rec = BTreeMap::new();
rec.insert("enabled".to_string(), Value::Bool(false));
rec.insert(
"status".to_string(),
Value::Str("telemetry disabled".to_string()),
);
Ok(Value::Record(rec))
}
/// Get current telemetry status.
/// Usage: telemetry.status()
fn bi_telemetry_status(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let enabled = TELEMETRY_ENABLED
.read()
.map_err(|_| crate::safety::lock_poisoned("telemetry_status", "the telemetry switch"))?;
let events = TELEMETRY_EVENTS.read().map_err(|_| {
crate::safety::lock_poisoned("telemetry_status", "the telemetry event buffer")
})?;
let mut rec = BTreeMap::new();
rec.insert("enabled".to_string(), Value::Bool(*enabled));
rec.insert("event_count".to_string(), Value::Int(events.len() as i64));
rec.insert("storage".to_string(), Value::Str("local".to_string()));
rec.insert(
"privacy".to_string(),
Value::Str("All data stored locally. No external transmission.".to_string()),
);
Ok(Value::Record(rec))
}
/// Generate a telemetry usage report.
/// Usage: telemetry.report() or telemetry.report({category?})
fn bi_telemetry_report(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let opts = match args.first() {
Some(Value::Record(r)) => r.clone(),
None => BTreeMap::new(),
Some(other) => {
return Err(crate::safety::bad_arg(
"telemetry_report",
"options: Record",
other.type_name(),
))
}
};
for (key, value) in &opts {
if key != "category" {
return Err(crate::safety::arg_err(format!(
"telemetry_report: unknown option {key}"
)));
}
expect_string("telemetry_report category", value)?;
}
let category_filter = opts.get("category").and_then(|v| {
if let Value::Str(s) = v {
Some(s.clone())
} else {
None
}
});
let events = TELEMETRY_EVENTS.read().map_err(|_| {
crate::safety::lock_poisoned("telemetry_report", "the telemetry event buffer")
})?;
// Group by category
let mut by_category: BTreeMap<String, i64> = BTreeMap::new();
let mut by_type: BTreeMap<String, i64> = BTreeMap::new();
let mut filtered_count = 0i64;
for event in events.iter() {
let include = category_filter
.as_ref()
.map(|c| event.category == *c)
.unwrap_or(true);
if include {
filtered_count += 1;
*by_category.entry(event.category.clone()).or_insert(0) += 1;
*by_type.entry(event.event_type.clone()).or_insert(0) += 1;
}
}
let categories: Vec<Value> = by_category
.iter()
.map(|(k, v)| {
let mut rec = BTreeMap::new();
rec.insert("category".to_string(), Value::Str(k.clone()));
rec.insert("count".to_string(), Value::Int(*v));
Value::Record(rec)
})
.collect();
let types: Vec<Value> = by_type
.iter()
.map(|(k, v)| {
let mut rec = BTreeMap::new();
rec.insert("event_type".to_string(), Value::Str(k.clone()));
rec.insert("count".to_string(), Value::Int(*v));
Value::Record(rec)
})
.collect();
let mut rec = BTreeMap::new();
rec.insert("total_events".to_string(), Value::Int(filtered_count));
rec.insert("by_category".to_string(), Value::Array(categories));
rec.insert("by_type".to_string(), Value::Array(types));
rec.insert(
"generated_at".to_string(),
Value::Str(chrono::Local::now().to_rfc3339()),
);
Ok(Value::Record(rec))
}
/// Get raw telemetry events.
/// Usage: telemetry.events() or telemetry.events(limit)
fn bi_telemetry_events(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let limit = match args.first() {
Some(Value::Int(n)) => *n as usize,
_ => 100,
};
let events = TELEMETRY_EVENTS.read().map_err(|_| {
crate::safety::lock_poisoned("telemetry_events", "the telemetry event buffer")
})?;
let entries: Vec<Value> = events
.iter()
.rev()
.take(limit)
.map(|e| {
let mut rec = BTreeMap::new();
rec.insert("id".to_string(), Value::Str(e.id.clone()));
rec.insert("event_type".to_string(), Value::Str(e.event_type.clone()));
rec.insert("category".to_string(), Value::Str(e.category.clone()));
rec.insert("timestamp".to_string(), Value::Str(e.timestamp.clone()));
rec.insert("session_id".to_string(), Value::Str(e.session_id.clone()));
if !e.data.is_empty() {
let data_rec: BTreeMap<String, Value> = e
.data
.iter()
.map(|(k, v)| (k.clone(), Value::Str(v.clone())))
.collect();
rec.insert("data".to_string(), Value::Record(data_rec));
}
Value::Record(rec)
})
.collect();
Ok(Value::Array(entries))
}
/// Reset (clear) all telemetry data.
/// Usage: telemetry.reset()
fn bi_telemetry_reset(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
if !args.is_empty() {
return Err(crate::safety::bad_arg(
"telemetry_reset",
"no arguments",
&format!("{} argument(s)", args.len()),
));
}
// Clears the in-process event buffer only. Nothing on disk is read or
// removed, so the status says exactly that.
let mut events = TELEMETRY_EVENTS.write().map_err(|_| {
crate::safety::lock_poisoned("telemetry_reset", "the telemetry event buffer")
})?;
let count = events.len();
events.clear();
let mut rec = BTreeMap::new();
rec.insert("cleared".to_string(), Value::Int(count as i64));
rec.insert(
"status".to_string(),
Value::Str("in-process telemetry events cleared".to_string()),
);
rec.insert(
"timestamp".to_string(),
Value::Str(chrono::Local::now().to_rfc3339()),
);
Ok(Value::Record(rec))
}
// ═══════════════════════════════════════════════════════════════════════════
// CLI Tool Wrappers — New cross-platform implementations (965-987)
// ═══════════════════════════════════════════════════════════════════════════
/// wget_download — download one http(s) URL to a file in the workspace.
/// Usage: wget_download(url) or wget_download(url, output). Without an output,
/// the file is the last path segment of the URL. The transfer is curl, limited
/// to http and https for the first request and for every redirect, with at most
/// 5 redirects, a 1 GiB size limit and a 15 s connect limit; wget cannot limit
/// the schemes it follows, so it is not used. An existing output is refused, and
/// a failed transfer removes its partial file. Returns {url, output, size, status, tool}.
fn bi_wget_download(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
net_refuse_in_transaction("wget_download")?;
k8s_no_input("wget_download", &input)?;
if args.len() > 2 {
return Err(crate::safety::arg_err(
"wget_download takes a URL and an optional output path",
));
}
let url = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => return Err(crate::safety::arg_err("wget requires a URL string")),
};
let parsed = url::Url::parse(&url).map_err(|e| {
crate::safety::bad_arg("wget_download", "an absolute http(s) URL", &e.to_string())
})?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(crate::safety::bad_arg(
"wget_download",
"an http or https URL",
&format!("the scheme {}", parsed.scheme()),
));
}
guard_network("wget_download", &url)?;
let output = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
Some(other) => {
return Err(crate::safety::bad_arg(
"wget_download",
"an output path String",
other.type_name(),
))
}
None => parsed
.path_segments()
.and_then(|segs| {
segs.rev()
.find(|s| !s.is_empty() && *s != "." && *s != "..")
})
.map(str::to_string)
.ok_or_else(|| {
crate::safety::arg_err("wget_download: the URL names no file; pass an output path")
})?,
};
crate::safety::reject_option_like("wget_download", std::slice::from_ref(&output))?;
if output.trim().is_empty() || output.len() > 4096 || output.contains('\0') {
return Err(crate::safety::arg_err(
"wget_download: the output path must be 1 to 4096 bytes without NUL",
));
}
let output = guard_local_write("wget_download", &output)?;
if std::path::Path::new(&output).exists() {
return Err(crate::safety::arg_err(format!(
"wget_download: `{output}` already exists; remove it or choose another output path"
)));
}
let max = WGET_FILE_MAX.to_string();
let report = format!("{WEB_HOP_MARK}%{{response_code}} %{{redirect_url}}");
// Redirects are followed here, hop by hop, for the reason `web_curl`
// gives: curl's own `-L` would take the request past the allowlist.
let mut hop_url = url.clone();
let mut hops = 0;
let fetched = loop {
let checked = match crate::security::check_http_url("wget_download", &hop_url) {
Ok(checked) => checked,
Err(e) => break Err(e),
};
let mut argv = vec![
"-sS",
"-f",
"--proto",
"=http,https",
"--connect-timeout",
"15",
"--max-filesize",
max.as_str(),
"-o",
output.as_str(),
"-w",
report.as_str(),
];
let pin = checked.curl_resolve();
if let Some(pin) = pin.as_deref() {
argv.extend_from_slice(&["--resolve", pin]);
}
argv.push(hop_url.as_str());
let run = match tool_run(
"wget_download",
"curl",
None,
&argv,
Keep::Tail(TOOL_STDERR_TAIL),
TOOL_LONG_DEADLINE_SECS,
)
.and_then(|run| finished("wget_download", run))
{
Ok(run) => run,
Err(e) => break Err(e),
};
let mut stdout = run.stdout.into_bytes();
let next = match web_take_hop(&mut stdout) {
Some((code, next)) if (300..400).contains(&code) && !next.is_empty() => next,
_ => break Ok(()),
};
hops += 1;
if hops > 5 {
break Err(crate::safety::budget_exceeded(
"wget_download",
"more than 5 redirects".to_string(),
"request the final URL directly",
));
}
let next = match crate::safety::guard_redirect("wget_download", &hop_url, &next)
.and_then(|_| web_http_url("wget_download", &next))
{
Ok(next) => next,
Err(e) => break Err(e),
};
hop_url = next;
};
if let Err(e) = fetched {
remove_partial(&output);
return Err(e);
}
let size = std::fs::metadata(&output)
.map_err(|e| crate::safety::fs_error("wget_download", &output, &e))?
.len();
let mut rec = BTreeMap::new();
rec.insert("url".to_string(), Value::Str(url));
rec.insert("output".to_string(), Value::Str(output));
rec.insert("size".to_string(), Value::Int(size as i64));
rec.insert("status".to_string(), Value::Str("downloaded".to_string()));
rec.insert("tool".to_string(), Value::Str("curl".to_string()));
Ok(Value::Record(rec))
}
/// An age recipient: an age1… key or an ssh- public key, and no other text.
fn age_recipient_ok(recipient: &str) -> bool {
(recipient.starts_with("age1") || recipient.starts_with("ssh-"))
&& recipient.len() <= 1024
&& recipient.chars().all(|c| {
c.is_ascii_alphanumeric() || matches!(c, ' ' | '-' | '_' | '+' | '/' | '=' | '.' | '@')
})
}
/// age — Encrypt a file to one recipient (an age1… or ssh- public key).
/// Usage: age_encrypt(input_file, output_file, recipient). The output is
/// required, and is the second argument because the recipient is the third. An
/// existing output is refused, and a failed run removes its partial file.
fn bi_age_encrypt(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("age_encrypt", &input)?;
let input_file = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(crate::safety::arg_err(
"age_encrypt requires input_file, output_file, recipient",
))
}
};
let output_file = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(crate::safety::arg_err(
"age_encrypt requires an output_file as its second argument",
))
}
};
let recipient = match args.get(2) {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(crate::safety::arg_err(
"age_encrypt requires a recipient (an age1… or ssh- public key)",
))
}
};
if !age_recipient_ok(&recipient) {
return Err(crate::safety::bad_arg(
"age_encrypt",
"recipient: an age1… or ssh- public key",
&recipient,
));
}
refuse_option("age_encrypt", &input_file)?;
let input_file = crate::safety::resolve_path_str(&input_file);
regular_file_arg("age_encrypt", &input_file)?;
let output_file = guard_local_write(
"age_encrypt",
&crate::safety::resolve_path_str(&output_file),
)?;
refuse_existing("age_encrypt", &output_file)?;
let mut cmd = crate::safety::child_command("age");
cmd.args([
"-r",
recipient.as_str(),
"-o",
output_file.as_str(),
input_file.as_str(),
]);
if let Err(e) = run_checked("age_encrypt", "age", cmd, LOCAL_DEADLINE_SECS) {
return Err(fail_removing_partial(e, &output_file));
}
let mut rec = BTreeMap::new();
rec.insert("input".to_string(), Value::Str(input_file));
rec.insert("output".to_string(), Value::Str(output_file));
rec.insert("recipient".to_string(), Value::Str(recipient));
rec.insert("status".to_string(), Value::Str("encrypted".to_string()));
Ok(Value::Record(rec))
}
/// age — Decrypt a file with an identity file.
/// Usage: age_decrypt(input_file, output_file?, identity_file?). Without an
/// output the input must end in .age. The output is refused if it exists.
fn bi_age_decrypt(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("age_decrypt", &input)?;
let input_file = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => return Err(crate::safety::arg_err("age_decrypt requires input_file")),
};
refuse_option("age_decrypt", &input_file)?;
let output_file = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
_ => match input_file.strip_suffix(".age") {
Some(stem) => stem.to_string(),
None => {
return Err(crate::safety::bad_arg(
"age_decrypt",
"an input ending in .age, or an output_file",
&input_file,
))
}
},
};
let identity = match args.get(2) {
Some(Value::Str(s)) => s.clone(),
_ => {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_default();
format!("{}/.age/key.txt", home)
}
};
refuse_option("age_decrypt", &identity)?;
if output_file == input_file {
return Err(crate::safety::bad_arg(
"age_decrypt",
"an output_file other than the input",
&output_file,
));
}
let input_file = crate::safety::resolve_path_str(&input_file);
regular_file_arg("age_decrypt", &input_file)?;
let identity = crate::safety::resolve_path_str(&identity);
regular_file_arg("age_decrypt", &identity)?;
let output_file = guard_local_write(
"age_decrypt",
&crate::safety::resolve_path_str(&output_file),
)?;
refuse_existing("age_decrypt", &output_file)?;
let mut cmd = crate::safety::child_command("age");
cmd.args([
"-d",
"-i",
identity.as_str(),
"-o",
output_file.as_str(),
input_file.as_str(),
]);
if let Err(e) = run_checked("age_decrypt", "age", cmd, LOCAL_DEADLINE_SECS) {
return Err(fail_removing_partial(e, &output_file));
}
let mut rec = BTreeMap::new();
rec.insert("input".to_string(), Value::Str(input_file));
rec.insert("output".to_string(), Value::Str(output_file));
rec.insert("status".to_string(), Value::Str("decrypted".to_string()));
Ok(Value::Record(rec))
}
/// age — Generate an age identity into a new file and return its public key.
/// Usage: age_keygen(output_file). The output file is required: the private key
/// is written there and is never returned, so it cannot land in a transcript.
/// An existing file is refused.
fn bi_age_keygen(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("age_keygen", &input)?;
let output_file = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(crate::safety::arg_err(
"age_keygen requires an output_file: the private key is written there and never returned",
))
}
};
refuse_option("age_keygen", &output_file)?;
let output_file =
guard_local_write("age_keygen", &crate::safety::resolve_path_str(&output_file))?;
refuse_existing("age_keygen", &output_file)?;
let mut cmd = crate::safety::child_command("age-keygen");
cmd.args(["-o", output_file.as_str()]);
// With -o, age-keygen writes the identity to the file and prints the public
// key on stderr. Its stdout is discarded, so the identity cannot be returned.
let (_, stderr) = match run_checked("age_keygen", "age-keygen", cmd, LOCAL_DEADLINE_SECS) {
Ok(done) => done,
Err(e) => return Err(fail_removing_partial(e, &output_file)),
};
let public_key = stderr
.lines()
.find_map(|l| l.strip_prefix("Public key:"))
.map(|k| k.trim().to_string())
.filter(|k| !k.is_empty());
let Some(public_key) = public_key else {
return Err(fail_removing_partial(
crate::safety::tool_failed("age_keygen", "age-keygen", Some(0), &stderr),
&output_file,
));
};
let mut rec = BTreeMap::new();
rec.insert("public_key".to_string(), Value::Str(public_key));
rec.insert("identity_file".to_string(), Value::Str(output_file));
rec.insert("status".to_string(), Value::Str("generated".to_string()));
Ok(Value::Record(rec))
}
/// ripgrep (rg) — Fast regex-based code search.
/// Usage: rg(pattern) or rg(pattern, path) or rg(pattern, path, flags_record)
/// Runs `rg --json --regexp=PATTERN PATH` with no shell. Exit 1 is "no matches"; any other
/// nonzero exit is E_TOOL_FAILED. A missing rg is E_TOOL_MISSING (no native fallback: a
/// different matcher would answer a different question). Over 1000 matches is E_BUDGET_EXCEEDED.
fn bi_rg_search(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "rg_search";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 3)?;
let pattern = k8s_need(B, "a search pattern", &args, 0, search_text_check)?;
let path = pkg_path_arg(B, &args, 1, ".")?;
let ignore_case =
match devtool_options(B, &args, 2, &["ignore_case"])?.and_then(|o| o.get("ignore_case")) {
None => false,
Some(Value::Bool(b)) => *b,
Some(other) => {
return Err(crate::safety::arg_err(format!(
"{B}: ignore_case must be a Bool, got {}",
other.type_name()
)))
}
};
let regexp = format!("--regexp={pattern}");
let mut argv = vec!["--json"];
if ignore_case {
argv.push("-i");
}
argv.push(regexp.as_str());
argv.push(path.as_str());
let run = tool_run(
B,
"rg",
None,
&argv,
Keep::Head(TOOL_MAX_JSON),
TOOL_DEADLINE_SECS,
)?;
let command = run.command.clone();
let text = stdout_with_exit1(B, run)?;
let mut matches = Vec::new();
for line in text.lines().filter(|l| !l.is_empty()) {
let parsed = serde_json::from_str::<serde_json::Value>(line).map_err(|e| {
crate::safety::tool_failed(
B,
&command,
Some(0),
&format!("printed a line that is not JSON ({e})"),
)
})?;
if parsed.get("type").and_then(|t| t.as_str()) != Some("match") {
continue;
}
let Some(data) = parsed.get("data") else {
continue;
};
if matches.len() >= SEARCH_MAX_RESULTS {
return Err(search_budget(B));
}
let mut rec = BTreeMap::new();
if let Some(p) = data
.get("path")
.and_then(|p| p.get("text"))
.and_then(|t| t.as_str())
{
rec.insert("file".to_string(), Value::Str(p.to_string()));
}
if let Some(n) = data.get("line_number").and_then(|n| n.as_i64()) {
rec.insert("line".to_string(), Value::Int(n));
}
if let Some(t) = data
.get("lines")
.and_then(|l| l.get("text"))
.and_then(|t| t.as_str())
{
rec.insert("text".to_string(), Value::Str(t.trim_end().to_string()));
}
matches.push(Value::Record(rec));
}
Ok(Value::Array(matches))
}
/// fd — Fast, user-friendly file finder.
/// Usage: fd(pattern) or fd(pattern, path) or fd(pattern, path, {type?, extension?})
/// Runs `fd` with the pattern after `--`, so a leading '-' is never a flag. A missing fd is
/// E_TOOL_MISSING; there is no native fallback, since find's glob is not fd's regex.
fn bi_fd_find(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "fd_find";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 3)?;
let pattern = k8s_need(B, "a search pattern", &args, 0, search_text_check)?;
let path = pkg_path_arg(B, &args, 1, ".")?;
let options = devtool_options(B, &args, 2, &["type", "extension"])?;
let kind = devtool_opt_str(B, "type", options, "type", fd_kind_check)?;
let extension = devtool_opt_str(B, "extension", options, "extension", fd_ext_check)?;
let mut argv: Vec<&str> = Vec::new();
if let Some(kind) = kind.as_deref() {
argv.extend(["--type", kind]);
}
if let Some(extension) = extension.as_deref() {
argv.extend(["--extension", extension]);
}
// fd's pattern is positional: `--` keeps a pattern that begins with '-' from
// being read as a flag.
argv.extend(["--", pattern.as_str(), path.as_str()]);
let run = tool_run(
B,
"fd",
None,
&argv,
Keep::Head(TOOL_MAX_TEXT),
TOOL_DEADLINE_SECS,
)?;
let text = tool_text(B, run)?;
search_list(
B,
text.lines()
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect(),
)
}
/// sed — Stream editor for text substitution (cross-platform, native Rust).
/// Usage: sed(pattern, replacement, input_text) or sed(pattern, replacement, input_text, {global?, file?})
fn bi_sed_replace(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let mut args = args.into_iter();
let Some(Value::Str(pattern)) = args.next() else {
return Err(crate::safety::arg_err("sed requires pattern String"));
};
let Some(Value::Str(replacement)) = args.next() else {
return Err(crate::safety::arg_err("sed requires replacement String"));
};
let (text, options) = match input {
Some(Value::Str(text)) => {
let options = args.next();
if args.next().is_some() {
return Err(crate::safety::arg_err(
"piped sed accepts pattern, replacement and optional options only",
));
}
(text, options)
}
Some(_) => return Err(crate::safety::arg_err("sed piped input must be String")),
None => {
let Some(Value::Str(text)) = args.next() else {
return Err(crate::safety::arg_err(
"sed requires text or file path String",
));
};
(text, args.next())
}
};
let (mut global, mut file_mode) = (true, false);
if let Some(options) = options {
let Value::Record(record) = options else {
return Err(crate::safety::arg_err("sed options must be Record"));
};
for (key, value) in record {
let slot = match key.as_str() {
"global" => &mut global,
"file" => &mut file_mode,
_ => return Err(crate::safety::arg_err(format!("sed unknown option {key}"))),
};
let Value::Bool(flag) = value else {
return Err(crate::safety::arg_err(format!("sed {key} must be Bool")));
};
*slot = flag;
}
}
let re = regex::Regex::new(&pattern)
.map_err(|e| crate::safety::arg_err(format!("sed invalid regex: {e}")))?;
let file = if file_mode {
Some((text.clone(), crate::safety::resolve_path_str(&text)))
} else {
None
};
let source = if let Some((_, path)) = &file {
crate::safety::guard(crate::safety::GuardCtx {
builtin: "sed_replace",
effect: crate::safety::Effect::WriteLocal,
what: "replace file text",
targets: vec![path.clone()],
blast_radius: serde_json::json!({"files":1}),
reversible: true,
fs_paths: true,
})?;
line_read("sed", path)?
} else {
text
};
let result = regex_substitute(&re, &replacement, source, global);
if let Some((original, path)) = file {
crate::tx::snapshot(&path);
write_file_atomically("sed", &path, result.as_bytes())?;
Ok(Value::Record(BTreeMap::from([
("file".into(), Value::Str(original)),
("status".into(), Value::Str("modified".into())),
])))
} else {
Ok(Value::Str(result))
}
}
/// awk — bounded in-process awk subset (see src/awk.rs for the grammar and limits).
/// Usage: awk(program, input_text, options?) or piped: text | awk(program, options?)
/// Options: {fs: String} sets the initial field separator (default one space).
#[path = "awk.rs"]
mod awk;
fn bi_awk_process(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let mut args = args.into_iter();
let program = match args.next() {
Some(Value::Str(s)) => s,
_ => return Err(crate::safety::arg_err("awk requires a program String")),
};
let (text, options) = match input {
Some(Value::Str(text)) => {
let options = args.next();
if args.next().is_some() {
return Err(crate::safety::arg_err(
"piped awk accepts program and optional options only",
));
}
(text, options)
}
Some(_) => return Err(crate::safety::arg_err("awk piped input must be String")),
None => {
let Some(Value::Str(text)) = args.next() else {
return Err(crate::safety::arg_err("awk requires input String"));
};
(text, args.next())
}
};
let mut separator = " ".to_string();
if let Some(options) = options {
let Value::Record(record) = options else {
return Err(crate::safety::arg_err("awk options must be Record"));
};
for (key, value) in record {
if key != "fs" {
return Err(crate::safety::arg_err(format!("awk unknown option {key}")));
}
let Value::Str(value) = value else {
return Err(crate::safety::arg_err("awk fs must be String"));
};
separator = value;
}
}
let lines = awk::run(&program, &text, &separator)?;
Ok(Value::Array(lines.into_iter().map(Value::Str).collect()))
}
/// cut — Extract columns/fields from text (cross-platform, native Rust).
/// Usage: cut(delimiter, fields, input_text) or piped: text | cut(delimiter, fields)
/// fields is a string like "1,3" or "1-3" (1-based)
fn bi_cut_columns(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
if input.is_some() && args.len() > 2 {
return Err(crate::safety::arg_err(
"piped cut accepts delimiter and fields only",
));
}
let delimiter = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(crate::safety::arg_err(
"cut requires delimiter, fields, and input",
))
}
};
let fields_spec = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
Some(Value::Int(n)) => n.to_string(),
_ => {
return Err(crate::safety::arg_err(
"cut requires a fields specification (e.g., '1,3' or '1-3')",
))
}
};
let text = match args.get(2) {
Some(Value::Str(s)) => s.clone(),
None => match &input {
Some(Value::Str(s)) => s.clone(),
_ => return Err(crate::safety::arg_err("cut requires input text")),
},
_ => return Err(crate::safety::arg_err("cut input must be a string")),
};
// Parse field spec: "1,3,5" or "1-3" or "2"
let field_indices = parse_field_spec(&fields_spec)?;
let mut results = Vec::new();
for line in text.lines() {
let cols: Vec<&str> = line.split(&delimiter).collect();
let selected: Vec<&str> = field_indices
.iter()
.filter_map(|&i| cols.get(i - 1).copied())
.collect();
results.push(Value::Str(selected.join(&delimiter)));
}
Ok(Value::Array(results))
}
/// Parse a field specification like "1,3,5" or "1-3" into a Vec of 1-based indices.
fn parse_field_spec(spec: &str) -> Result<Vec<usize>> {
const MAX_FIELDS: usize = 10000;
let invalid = || {
crate::safety::arg_err("cut fields must be positive integers or ascending inclusive ranges, separated by commas")
};
let parse = |part: &str| -> Result<usize> {
if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) {
return Err(invalid());
}
let n = part.parse::<usize>().map_err(|_| invalid())?;
if n == 0 {
return Err(invalid());
}
Ok(n)
};
let mut indices = Vec::new();
for part in spec.split(',') {
let part = part.trim();
let (start, end) = if let Some((left, right)) = part.split_once('-') {
(parse(left)?, parse(right)?)
} else {
let n = parse(part)?;
(n, n)
};
if start > end {
return Err(invalid());
}
let length = end - start + 1;
if length > MAX_FIELDS - indices.len() {
return Err(crate::safety::budget_exceeded(
"cut",
"field selection exceeds 10000 entries".into(),
"select fewer fields",
));
}
indices.extend(start..=end);
}
Ok(indices)
}
/// xargs — Run one program once per item, with the item as its last argument.
/// Usage: xargs_exec(command, items) or items | xargs_exec(command). items is an
/// Array of scalars, or a String read as lines. The command is split on whitespace
/// and run directly, with no shell; its program may not begin with '-', and no
/// item may either. At most 256 items, each run has a 120 s deadline, and the call
/// as a whole 600 s. Returns one Record per item: {item, exit_code, stdout, stderr}.
/// A nonzero exit is that item's exit code, which is data here.
fn bi_xargs_exec(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_max_args("xargs_exec", &args, 2)?;
let command = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => return Err(crate::safety::arg_err("xargs requires a command string")),
};
let items = match (args.get(1), &input) {
(Some(_), Some(_)) => {
return Err(crate::safety::arg_err(
"xargs_exec takes its items either as an argument or piped in, not both",
))
}
(Some(v), None) | (None, Some(v)) => xargs_items("xargs_exec", v)?,
(None, None) => return Err(crate::safety::arg_err("xargs requires input items")),
};
let words = argv_words("xargs_exec", &command)?;
crate::safety::guard_exec("xargs", &command)?;
let started = std::time::Instant::now();
let mut results = Vec::with_capacity(items.len());
for item in &items {
if started.elapsed() > std::time::Duration::from_secs(XARGS_TOTAL_SECS) {
return Err(crate::safety::budget_exceeded(
"xargs_exec",
format!(
"the items took more than {XARGS_TOTAL_SECS} s; {} of {} ran",
results.len(),
items.len()
),
"run the remaining items in a separate call",
));
}
let mut argv: Vec<&str> = words.iter().skip(1).map(String::as_str).collect();
argv.push(item.as_str());
let run = tool_run_stderr(
"xargs_exec",
&words[0],
None,
&argv,
Keep::Tail(XARGS_ITEM_OUT),
XARGS_ITEM_OUT,
TOOL_DEADLINE_SECS,
)?;
let mut rec = BTreeMap::new();
rec.insert("item".to_string(), Value::Str(item.clone()));
rec.insert("exit_code".to_string(), Value::Int(exit_code_of(&run)));
rec.insert(
"stdout".to_string(),
Value::Str(run.stdout.trim().to_string()),
);
rec.insert("stderr".to_string(), Value::Str(run.stderr.clone()));
results.push(Value::Record(rec));
}
Ok(Value::Array(results))
}
/// The JSON text a jq builtin reads: its second argument, or the piped string.
/// Text past JQ_INPUT_CAP is refused rather than parsed.
fn jq_json_arg(builtin: &str, args: &[Value], input: Option<Value>) -> Result<String> {
let text = match (args.get(1), input) {
(Some(Value::Str(s)), _) => s.clone(),
(None, Some(Value::Str(s))) => s,
(None, _) => {
return Err(crate::safety::arg_err(format!(
"{builtin} requires JSON input"
)))
}
_ => {
return Err(crate::safety::bad_arg(
builtin,
"JSON text: String",
"a non-string",
))
}
};
if text.len() > JQ_INPUT_CAP {
return Err(crate::safety::budget_exceeded(
builtin,
format!(
"JSON input is {} bytes; the limit is {JQ_INPUT_CAP}",
text.len()
),
"pass a smaller document",
));
}
Ok(text)
}
/// jq — Query and transform JSON data (cross-platform, native Rust).
/// Usage: jq(expression, json_string) or piped: json_str | jq(expression)
/// Evaluates the subset that eval_jq_expr documents. Anything else is refused,
/// never answered with a guess.
fn bi_jq_query(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let expr = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => return Err(crate::safety::arg_err("jq requires a query expression")),
};
let json_str = jq_json_arg("jq", &args, input)?;
let parsed: serde_json::Value = serde_json::from_str(&json_str)
.map_err(|e| crate::safety::bad_arg("jq", "valid JSON input", &e.to_string()))?;
let result = eval_jq_expr(&expr, &parsed)?;
Ok(json_to_value(result))
}
/// jq filter — Apply a real jq filter to JSON, with the jq binary.
/// Usage: jq_filter(filter, json_string) or piped: json_str | jq_filter(filter)
/// A filter that begins with '-' is refused: jq would read it as an option.
/// With no jq on PATH this is E_TOOL_MISSING. It does not fall back to the native subset.
fn bi_jq_filter(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let filter = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(crate::safety::arg_err(
"jq_filter requires a filter expression",
))
}
};
refuse_option("jq_filter", &filter)?;
let json_str = jq_json_arg("jq_filter", &args, input)?;
let mut cmd = crate::safety::child_command("jq");
cmd.arg(filter.as_str());
let out = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
let (status, stderr) = run_tool(
"jq_filter",
"jq",
cmd,
Some(json_str.into_bytes()),
std::sync::Arc::clone(&out),
LOCAL_DEADLINE_SECS,
TOOL_OUTPUT_CAP,
)?;
tool_ok("jq_filter", "jq", status, &stderr)?;
let bytes = std::mem::take(
&mut *out
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
);
let stdout = String::from_utf8_lossy(&bytes).trim().to_string();
match serde_json::from_str::<serde_json::Value>(&stdout) {
Ok(parsed) => Ok(json_to_value(parsed)),
Err(_) => Ok(Value::Str(stdout)),
}
}
/// Native jq expression evaluator. Supports `.`, `.key`, `.key.sub`, `.[n]`,
/// `.[]` (returns the elements as an array), `keys`, `length` and `|` between
/// them. `length` follows jq: the absolute value of a number, and an error for a
/// boolean. Anything else, and any index that does not fit, is refused.
fn eval_jq_expr(expr: &str, data: &serde_json::Value) -> Result<serde_json::Value> {
let expr = expr.trim();
let unsupported = || {
crate::safety::bad_arg(
"jq",
"an expression of . .key .key.sub .[n] .[] keys length, joined by |",
expr,
)
};
if expr == "." {
return Ok(data.clone());
}
if expr == "keys" {
if let serde_json::Value::Object(m) = data {
return Ok(serde_json::Value::Array(
m.keys()
.map(|k| serde_json::Value::String(k.clone()))
.collect(),
));
}
return Err(crate::safety::arg_err("keys requires an object"));
}
if expr == "length" {
return match data {
serde_json::Value::Null => Ok(serde_json::json!(0)),
serde_json::Value::Bool(_) => Err(crate::safety::bad_arg(
"jq",
"a value with a length (null, number, string, array or object)",
"a boolean",
)),
serde_json::Value::Number(n) => Ok(match n.as_i64() {
Some(i) => serde_json::json!(i.unsigned_abs()),
None => serde_json::json!(n.as_f64().map_or(0.0, f64::abs)),
}),
// jq counts code points, not bytes.
serde_json::Value::String(s) => Ok(serde_json::json!(s.chars().count())),
serde_json::Value::Array(a) => Ok(serde_json::json!(a.len())),
serde_json::Value::Object(m) => Ok(serde_json::json!(m.len())),
};
}
if expr == ".[]" {
if let serde_json::Value::Array(a) = data {
return Ok(serde_json::Value::Array(a.clone()));
}
if let serde_json::Value::Object(m) = data {
return Ok(serde_json::Value::Array(m.values().cloned().collect()));
}
return Err(crate::safety::bad_arg(
"jq",
"an array or object to iterate",
"another value",
));
}
// Handle pipe: .key | .subkey
if let Some(pipe_pos) = expr.find(" | ") {
let left = &expr[..pipe_pos];
let right = &expr[pipe_pos + 3..];
let intermediate = eval_jq_expr(left, data)?;
return eval_jq_expr(right, &intermediate);
}
// Dot-path: .key.subkey or .key
if let Some(path) = expr.strip_prefix('.') {
// Handle array index: .[0]
if path.starts_with('[') && path.ends_with(']') {
let idx_str = &path[1..path.len() - 1];
let idx: usize = idx_str.parse().map_err(|_| {
crate::safety::bad_arg("jq", "a numeric index in brackets", idx_str)
})?;
return match data {
serde_json::Value::Array(a) => a.get(idx).cloned().ok_or_else(|| {
crate::safety::bad_arg(
"jq",
&format!("an index below {}", a.len()),
&idx.to_string(),
)
}),
_ => Err(crate::safety::bad_arg(
"jq",
"an array to index",
"a non-array",
)),
};
}
let mut current = data;
for key in path.split('.') {
if key.is_empty() {
continue;
}
// Handle key[n] notation. A bracket that is not a number is refused,
// not dropped.
if let Some(bracket) = key.find('[').filter(|_| key.ends_with(']')) {
let obj_key = &key[..bracket];
let idx_str = &key[bracket + 1..key.len() - 1];
if !obj_key.is_empty() {
current = current
.get(obj_key)
.ok_or_else(|| crate::safety::not_found("jq", "key", obj_key))?;
}
let idx: usize = idx_str.parse().map_err(|_| {
crate::safety::bad_arg("jq", "a numeric index in brackets", idx_str)
})?;
current = match current {
serde_json::Value::Array(a) => a.get(idx).ok_or_else(|| {
crate::safety::bad_arg(
"jq",
&format!("an index below {}", a.len()),
&idx.to_string(),
)
})?,
_ => {
return Err(crate::safety::bad_arg(
"jq",
"an array to index",
"a non-array",
))
}
};
} else {
current = current
.get(key)
.ok_or_else(|| crate::safety::not_found("jq", "key", key))?;
}
}
return Ok(current.clone());
}
Err(unsupported())
}
/// yq — Query/transform YAML, TOML, XML data.
/// Usage: yq(expression, input_string) or piped input.
/// Runs `yq EXPRESSION FILE`, where FILE holds the document (a temporary file, since the
/// runner gives a child no stdin). A missing yq is E_TOOL_MISSING; nothing is parsed natively.
fn bi_yq_query(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "yq_query";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 2)?;
let expr = k8s_need(B, "an expression", &args, 0, pkg_plain)?;
let document = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
Some(other) => {
return Err(crate::safety::arg_err(format!(
"{B}: the document must be a String, got {}",
other.type_name()
)))
}
None => {
return Err(crate::safety::arg_err(format!(
"{B} requires the document text"
)))
}
};
if document.len() > TOOL_MAX_TEXT {
return Err(crate::safety::budget_exceeded(
B,
format!("the document is larger than {TOOL_MAX_TEXT} bytes"),
"pass a smaller document",
));
}
// yq reads its document from a file here, because the runner gives a child
// no stdin. The file lives in a directory of its own, removed afterwards.
let file = scratch_file(B, "document.yaml", document.as_bytes())?;
let path = file.display().to_string();
let run = tool_run(
B,
"yq",
None,
&[expr.as_str(), path.as_str()],
Keep::Head(TOOL_MAX_TEXT),
TOOL_DEADLINE_SECS,
);
scratch_remove(&file);
let text = tool_text(B, run?)?;
Ok(Value::Str(text.trim().to_string()))
}
/// pager/less — Display text with pagination (cross-platform).
/// Usage: less(text) or less(file_path, {file: true})
fn bi_pager(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let mut args = args.into_iter();
let (text, options) = match input {
Some(Value::Str(text)) => {
let options = args.next();
if args.next().is_some() {
return Err(crate::safety::arg_err(
"piped pager accepts optional options only",
));
}
(text, options)
}
Some(_) => return Err(crate::safety::arg_err("pager piped input must be String")),
None => {
let Some(Value::Str(text)) = args.next() else {
return Err(crate::safety::arg_err(
"pager requires text or file path String",
));
};
(text, args.next())
}
};
let mut file = false;
if let Some(options) = options {
let Value::Record(record) = options else {
return Err(crate::safety::arg_err("pager options must be Record"));
};
for (key, value) in record {
if key != "file" {
return Err(crate::safety::arg_err(format!(
"pager unknown option {key}"
)));
}
let Value::Bool(flag) = value else {
return Err(crate::safety::arg_err("pager file must be Bool"));
};
file = flag;
}
}
let content = if file {
let path = crate::safety::resolve_path_str(&text);
crate::safety::guard(crate::safety::GuardCtx {
builtin: "pager",
effect: crate::safety::Effect::ReadLocal,
what: "read paged file text",
targets: vec![path.clone()],
blast_radius: serde_json::json!({"files":1}),
reversible: true,
fs_paths: true,
})?;
line_read("pager", &path)?
} else {
text
};
const PAGE_SIZE: usize = 40;
let mut lines = content.lines();
let first: Vec<_> = lines.by_ref().take(PAGE_SIZE).collect();
let total = first.len() + lines.count();
if total <= PAGE_SIZE {
return Ok(Value::Str(content));
}
Ok(Value::Record(BTreeMap::from([
("content".into(), Value::Str(first.join("\n"))),
("total_lines".into(), Value::Int(total as i64)),
("page_size".into(), Value::Int(PAGE_SIZE as i64)),
("pages".into(), Value::Int(total.div_ceil(PAGE_SIZE) as i64)),
(
"hint".into(),
Value::Str("Use head/tail for specific line ranges".into()),
),
])))
}
/// tee — Write input to both a file and return it (cross-platform, native Rust).
/// Usage: tee(file_path, text) or piped: text | tee(file_path)
fn bi_tee_output(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let path = crate::safety::resolve_path_str(&expect_string(
"tee",
args.first()
.ok_or_else(|| crate::safety::arg_err("tee requires path"))?,
)?);
let (value, options) = match input {
Some(value) => {
if args.len() > 2 {
return Err(crate::safety::arg_err(
"piped tee accepts path and optional options only",
));
}
(value, args.get(1))
}
None => (
args.get(1)
.cloned()
.ok_or_else(|| crate::safety::arg_err("tee requires data"))?,
args.get(2),
),
};
let mut append = false;
if let Some(options) = options {
let Value::Record(record) = options else {
return Err(crate::safety::arg_err("tee options must be Record"));
};
for (key, value) in record {
if key != "append" {
return Err(crate::safety::arg_err(format!("tee unknown option {key}")));
}
let Value::Bool(flag) = value else {
return Err(crate::safety::arg_err("tee append must be Bool"));
};
append = *flag;
}
}
let text = match value {
Value::Str(text) => text,
other => format!("{other}"),
};
crate::safety::guard(crate::safety::GuardCtx {
builtin: "tee_output",
effect: crate::safety::Effect::WriteLocal,
what: if append { "append" } else { "write" },
targets: vec![path.clone()],
blast_radius: serde_json::json!({"bytes":text.len()}),
reversible: true,
fs_paths: true,
})?;
crate::tx::snapshot(&path);
if append {
use std::io::Write;
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| crate::safety::fs_error("tee", &path, &e))?;
file.write_all(text.as_bytes())
.map_err(|e| crate::safety::fs_error("tee", &path, &e))?;
} else {
write_file_atomically("tee", &path, text.as_bytes())?;
}
Ok(Value::Str(text))
}
/// Longest tmux or screen session name.
const DEVTOOL_MAX_NAME: usize = 64;
/// Longest tmux target: a session, or session:window.pane.
const DEVTOOL_MAX_TARGET: usize = 256;
/// Longest key text sent to a session in one call.
const DEVTOOL_MAX_KEYS: usize = 4096;
/// Longest single argument passed through to a dev tool.
const DEVTOOL_MAX_ARG: usize = 4096;
/// Most items in any list an options Record or a call takes (cmake defines,
/// gdb commands, recipe arguments).
const DEVTOOL_MAX_ITEMS: usize = 256;
/// Longest single gdb command line.
const DEVTOOL_MAX_GDB_LINE: usize = 512;
/// Most parallel jobs a build may ask for.
const DEVTOOL_MAX_JOBS: i64 = 1024;
/// The process's working directory, as a dev tool would inherit it.
fn devtool_cwd(builtin: &str) -> Result<std::path::PathBuf> {
std::env::current_dir().map_err(|e| crate::safety::fs_error(builtin, ".", &e))
}
/// A directory a dev tool runs in: `value` (default: the process's directory)
/// resolved against the process's directory, required to lie inside the
/// workspace, and required to exist as a directory.
fn devtool_dir(builtin: &str, value: Option<&str>) -> Result<std::path::PathBuf> {
let cwd = devtool_cwd(builtin)?;
let dir = workspace_path(builtin, value.unwrap_or("."), &cwd)?;
if !dir.is_dir() {
return Err(crate::safety::not_found(
builtin,
"directory",
&dir.display().to_string(),
));
}
Ok(dir)
}
/// The options Record at `index`, when one is given. Only the keys in
/// `allowed` are accepted; any other key is refused by name, so no option is
/// silently ignored.
fn devtool_options<'a>(
builtin: &str,
args: &'a [Value],
index: usize,
allowed: &[&str],
) -> Result<Option<&'a BTreeMap<String, Value>>> {
match args.get(index) {
None => Ok(None),
Some(Value::Record(map)) => {
if let Some(key) = map.keys().find(|k| !allowed.contains(&k.as_str())) {
return Err(crate::safety::arg_err(format!(
"{builtin}: unknown option '{key}'; the options are {}",
allowed.join(", ")
)));
}
Ok(Some(map))
}
Some(other) => Err(crate::safety::arg_err(format!(
"{builtin}: options must be a Record, got {}",
other.type_name()
))),
}
}
/// A String option, checked, when it is present.
fn devtool_opt_str(
builtin: &str,
what: &str,
options: Option<&BTreeMap<String, Value>>,
key: &str,
check: K8sCheck,
) -> Result<Option<String>> {
match options.and_then(|o| o.get(key)) {
None => Ok(None),
Some(Value::Str(s)) => check(builtin, what, s).map(Some),
Some(other) => Err(crate::safety::arg_err(format!(
"{builtin}: {what} must be a String, got {}",
other.type_name()
))),
}
}
/// An Int option, range-checked, when it is present.
fn devtool_opt_int(
builtin: &str,
what: &str,
options: Option<&BTreeMap<String, Value>>,
key: &str,
lo: i64,
hi: i64,
) -> Result<Option<i64>> {
match options.and_then(|o| o.get(key)) {
None => Ok(None),
Some(Value::Int(n)) if (lo..=hi).contains(n) => Ok(Some(*n)),
Some(Value::Int(n)) => Err(crate::safety::arg_err(format!(
"{builtin}: {what} must be {lo}..={hi}, got {n}"
))),
Some(other) => Err(crate::safety::arg_err(format!(
"{builtin}: {what} must be an Int, got {}",
other.type_name()
))),
}
}
/// A tmux or screen session name: 1-64 letters, digits, '-' or '_'. Colons and
/// dots are refused because tmux reads them as target separators, and a
/// leading '-' is refused as an option.
fn devtool_session(builtin: &str, what: &str, value: &str) -> Result<String> {
crate::safety::reject_option_like(builtin, &[value.to_string()])?;
let well_formed = !value.is_empty()
&& value.len() <= DEVTOOL_MAX_NAME
&& value
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
if !well_formed {
return Err(crate::safety::arg_err(format!(
"{builtin}: {what} is 1-{DEVTOOL_MAX_NAME} letters, digits, '-' or '_' (got {value:?})"
)));
}
Ok(value.to_string())
}
/// A tmux target: a session, or session:window.pane. One word, not option-like.
fn devtool_target(builtin: &str, what: &str, value: &str) -> Result<String> {
crate::safety::reject_option_like(builtin, &[value.to_string()])?;
let well_formed = !value.is_empty()
&& value.len() <= DEVTOOL_MAX_TARGET
&& !value.chars().any(|c| c.is_whitespace() || c.is_control());
if !well_formed {
return Err(crate::safety::arg_err(format!(
"{builtin}: {what} is one word of at most {DEVTOOL_MAX_TARGET} characters (got {value:?})"
)));
}
Ok(value.to_string())
}
/// Keys typed into a session: one line, no control characters (each newline
/// would be a second command), not option-like.
fn devtool_keys(builtin: &str, what: &str, value: &str) -> Result<String> {
crate::safety::reject_option_like(builtin, &[value.to_string()])?;
if value.is_empty() || value.len() > DEVTOOL_MAX_KEYS || value.chars().any(char::is_control) {
return Err(crate::safety::arg_err(format!(
"{builtin}: {what} is one line of 1-{DEVTOOL_MAX_KEYS} bytes with no control characters"
)));
}
Ok(value.to_string())
}
/// A tool-and-version spec such as `node@20`: one word, not option-like.
fn devtool_tool_spec(builtin: &str, what: &str, value: &str) -> Result<String> {
if value.chars().any(char::is_whitespace) {
return Err(crate::safety::arg_err(format!(
"{builtin}: {what} is one word such as node@20 (got {value:?})"
)));
}
tf_plain(builtin, what, value)
}
/// A string argument that goes to a tool as data: no NUL byte.
fn devtool_text_arg(builtin: &str, what: &str, value: &str) -> Result<String> {
if value.contains('\0') {
return Err(crate::safety::arg_err(format!(
"{builtin}: {what} must not contain a NUL byte"
)));
}
Ok(value.to_string())
}
/// A word passed to a dev tool as one recipe or program argument. A leading
/// '-' is refused, because the tool would read it as an option.
fn devtool_word(builtin: &str, what: &str, value: &str) -> Result<String> {
crate::safety::reject_option_like(builtin, &[value.to_string()])?;
if value.len() > DEVTOOL_MAX_ARG || value.contains('\0') {
return Err(crate::safety::arg_err(format!(
"{builtin}: {what} is at most {DEVTOOL_MAX_ARG} bytes with no NUL byte"
)));
}
Ok(value.to_string())
}
/// A gdb command script, one command per line. Each line becomes one `-ex`
/// argument. gdb can run shell commands, so the call is approval-gated as an
/// exec by the central gate.
fn devtool_gdb_commands(builtin: &str, text: &str) -> Result<Vec<String>> {
let lines: Vec<String> = text
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect();
if lines.is_empty() {
return Err(crate::safety::arg_err(format!(
"{builtin}: commands must contain at least one gdb command"
)));
}
if lines.len() > DEVTOOL_MAX_ITEMS {
return Err(crate::safety::arg_err(format!(
"{builtin}: at most {DEVTOOL_MAX_ITEMS} gdb commands, got {}",
lines.len()
)));
}
if let Some(long) = lines.iter().find(|l| l.len() > DEVTOOL_MAX_GDB_LINE) {
return Err(crate::safety::arg_err(format!(
"{builtin}: a gdb command is at most {DEVTOOL_MAX_GDB_LINE} bytes, got {}",
long.len()
)));
}
Ok(lines)
}
/// `-Dname=value` for each define in a cmake defines Record. Names are letters,
/// digits and `_.-`; values are String, Int or Bool (ON/OFF).
fn devtool_defines(builtin: &str, value: &Value) -> Result<Vec<String>> {
let defs = match value {
Value::Record(map) => map,
other => {
return Err(crate::safety::arg_err(format!(
"{builtin}: defines must be a Record, got {}",
other.type_name()
)))
}
};
if defs.len() > DEVTOOL_MAX_ITEMS {
return Err(crate::safety::arg_err(format!(
"{builtin}: at most {DEVTOOL_MAX_ITEMS} defines, got {}",
defs.len()
)));
}
let mut out = Vec::with_capacity(defs.len());
for (key, val) in defs {
let well_formed = !key.is_empty()
&& key.len() <= 128
&& !key.starts_with('-')
&& key
.chars()
.all(|c| c.is_ascii_alphanumeric() || "_.-".contains(c));
if !well_formed {
return Err(crate::safety::arg_err(format!(
"{builtin}: a define name is letters, digits and _.- and does not start with '-' (got {key:?})"
)));
}
let text = match val {
Value::Str(s) => s.clone(),
Value::Int(n) => n.to_string(),
Value::Bool(b) => (if *b { "ON" } else { "OFF" }).to_string(),
other => {
return Err(crate::safety::arg_err(format!(
"{builtin}: define {key} must be a String, Int or Bool, got {}",
other.type_name()
)))
}
};
devtool_text_arg(builtin, "a define value", &text)?;
out.push(format!("-D{key}={text}"));
}
Ok(out)
}
/// A file read whole, at most `TOOL_MAX_TEXT` bytes. A larger file is refused
/// rather than read in part, and a missing one is E_NOT_FOUND.
fn devtool_read_text(builtin: &str, kind: &str, path: &str) -> Result<String> {
use std::io::Read;
let file = std::fs::File::open(path).map_err(|e| devtool_io(builtin, kind, path, &e))?;
let mut bytes = Vec::new();
file.take(TOOL_MAX_TEXT as u64 + 1)
.read_to_end(&mut bytes)
.map_err(|e| devtool_io(builtin, kind, path, &e))?;
if bytes.len() > TOOL_MAX_TEXT {
return Err(crate::safety::budget_exceeded(
builtin,
format!("`{path}` is larger than {TOOL_MAX_TEXT} bytes"),
"point this at a smaller file",
));
}
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
/// An I/O failure on a named file: NotFound is E_NOT_FOUND, anything else is
/// the file system's own coded error.
fn devtool_io(builtin: &str, kind: &str, path: &str, e: &std::io::Error) -> anyhow::Error {
if e.kind() == std::io::ErrorKind::NotFound {
crate::safety::not_found(builtin, kind, path)
} else {
crate::safety::fs_error(builtin, path, e)
}
}
/// A finished call that must have succeeded. A nonzero exit is E_TOOL_FAILED
/// with the tail of its stderr.
fn finished(builtin: &str, run: ToolRun) -> Result<ToolRun> {
if run.success {
return Ok(run);
}
let tail = tail_text(&run.stderr, TOOL_STDERR_TAIL);
Err(crate::safety::tool_failed(
builtin,
&run.command,
run.code,
&tail,
))
}
/// The last `max` bytes of `text`, cut on a character boundary.
fn tail_text(text: &str, max: usize) -> String {
if text.len() <= max {
return text.to_string();
}
let mut start = text.len() - max;
while !text.is_char_boundary(start) {
start += 1;
}
text[start..].to_string()
}
/// The fields every successful dev-tool record carries. The output keys are
/// named by the caller, because each builtin has always named them its own
/// way; `output_truncated` says when the stdout kept was only its tail.
fn run_fields(run: ToolRun, out_key: &str, err_key: &str) -> BTreeMap<String, Value> {
let mut rec = BTreeMap::new();
// No code means the process was killed by a signal (the deadline is
// refused before this). It was `0`, which read as a clean exit.
rec.insert(
"exit_code".to_string(),
match run.code {
Some(code) => Value::Int(code as i64),
None => Value::Null,
},
);
rec.insert("success".to_string(), Value::Bool(run.success));
if run.stdout_truncated {
rec.insert("output_truncated".to_string(), Value::Bool(true));
}
rec.insert(out_key.to_string(), Value::Str(run.stdout));
rec.insert(err_key.to_string(), Value::Str(run.stderr));
rec
}
/// An interactive attach needs a terminal on both ends. Agent mode has no
/// human to attach, and without a terminal the attach could only hang or fail,
/// so both are refused as E_NO_UI before anything is spawned.
fn devtool_terminal(builtin: &str) -> Result<()> {
crate::safety::refuse_if_headless(builtin)?;
use std::io::IsTerminal;
if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
Ok(())
} else {
Err(crate::safety::no_ui(builtin))
}
}
/// Run an attach with the terminal's streams inherited, so the user is the one
/// talking to the session. Only called after [`devtool_terminal`].
fn devtool_attach(builtin: &str, program: &str, args: &[&str], label: &str) -> Result<()> {
let mut cmd = crate::safety::child_command(program);
crate::safety::strip_ironshell_env(&mut cmd);
let status = cmd
.args(args)
.status()
.map_err(|e| crate::safety::spawn_error(builtin, program, &e))?;
if status.success() {
Ok(())
} else {
Err(crate::safety::tool_failed(
builtin,
label,
status.code(),
"its messages went to this terminal",
))
}
}
/// The lines of a list-style tool output: trimmed, non-empty, kept in order.
fn devtool_lines(text: &str) -> Vec<Value> {
text.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(|l| Value::Str(l.to_string()))
.collect()
}
/// valgrind under memcheck with the given flags. Its report is on stderr, so
/// that is the stream kept at the larger size; a nonzero exit is E_TOOL_FAILED
/// with the tail of that report.
fn devtool_valgrind(
builtin: &str,
flags: &[&str],
args: &[Value],
input: Option<Value>,
) -> Result<Value> {
k8s_no_input(builtin, &input)?;
let command = k8s_need(builtin, "program", args, 0, tf_plain)?;
let mut argv: Vec<&str> = flags.to_vec();
argv.push(command.as_str());
let run = tool_run_stderr(
builtin,
"valgrind",
None,
&argv,
Keep::Tail(TOOL_KEEP_TAIL),
TOOL_KEEP_TAIL,
TOOL_LONG_DEADLINE_SECS,
)?;
let mut rec = run_fields(finished(builtin, run)?, "stdout", "report");
rec.insert("command".to_string(), Value::Str(command));
Ok(Value::Record(rec))
}
/// make — Run one make target, or the default goal.
/// Usage: make_run() or make_run(target) or make_run(target, {dir?, file?, jobs?})
/// The target is one argv element, never a shell line, and it may not start
/// with '-'. `dir` and `file` must lie inside the workspace. Output keeps its
/// last 256 KiB. A nonzero exit is E_TOOL_FAILED. Effect Exec: a Makefile runs
/// its recipes.
fn bi_make_run(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("make_run", &input)?;
let target = k8s_arg("make_run", "target", &args, 0, tf_plain)?;
let options = devtool_options("make_run", &args, 1, &["dir", "file", "jobs"])?;
let dir_opt = devtool_opt_str("make_run", "dir", options, "dir", tf_plain)?;
let file_opt = devtool_opt_str("make_run", "file", options, "file", tf_plain)?;
let jobs = devtool_opt_int("make_run", "jobs", options, "jobs", 1, DEVTOOL_MAX_JOBS)?;
let dir = devtool_dir("make_run", dir_opt.as_deref())?;
let file = match &file_opt {
Some(f) => Some(workspace_path("make_run", f, &dir)?),
None => None,
};
if let Some(f) = &file {
if !f.is_file() {
return Err(crate::safety::not_found(
"make_run",
"makefile",
&f.display().to_string(),
));
}
}
let mut argv: Vec<String> = Vec::new();
if let Some(f) = &file {
argv.push("-f".to_string());
argv.push(f.display().to_string());
}
if let Some(j) = jobs {
argv.push(format!("-j{j}"));
}
if let Some(t) = &target {
argv.push(t.clone());
}
let refs: Vec<&str> = argv.iter().map(String::as_str).collect();
let run = finished(
"make_run",
tool_run(
"make_run",
"make",
Some(dir.as_path()),
&refs,
Keep::Tail(TOOL_KEEP_TAIL),
TOOL_LONG_DEADLINE_SECS,
)?,
)?;
let mut rec = run_fields(run, "stdout", "stderr");
if let Some(t) = target {
rec.insert("target".to_string(), Value::Str(t));
}
Ok(Value::Record(rec))
}
/// make — List the targets a Makefile names, read natively without running make.
/// Usage: make_targets() or make_targets(makefile_path)
/// A missing file is E_NOT_FOUND; a file over 4 MiB is refused.
fn bi_make_targets(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("make_targets", &input)?;
let makefile = k8s_arg("make_targets", "makefile path", &args, 0, tf_plain)?
.unwrap_or_else(|| "Makefile".to_string());
let content = devtool_read_text("make_targets", "makefile", &makefile)?;
let re = regex::Regex::new(r"^([a-zA-Z_][a-zA-Z0-9_.-]*)\s*:").unwrap();
let targets: Vec<Value> = content
.lines()
.filter_map(|line| re.captures(line).map(|cap| Value::Str(cap[1].to_string())))
.collect();
Ok(Value::Array(targets))
}
/// cmake — Configure a build directory from a source directory.
/// Usage: cmake_configure(source_dir) or cmake_configure(source_dir, build_dir, {generator?, defines?})
/// Both directories default to "." and "build" and must lie inside the
/// workspace; the build directory is created when missing. A define is
/// `-Dname=value`, one argument each. Configure runs the project's CMakeLists
/// code, yet the effect table classes cmake_configure as write_local, so the
/// central gate does not ask for approval; that classification is reported,
/// not changed here. A nonzero exit is
/// E_TOOL_FAILED. Takes no pipeline input.
fn bi_cmake_configure(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("cmake_configure", &input)?;
let source = k8s_arg("cmake_configure", "source directory", &args, 0, tf_plain)?;
let build = k8s_arg("cmake_configure", "build directory", &args, 1, tf_plain)?
.unwrap_or_else(|| "build".to_string());
let options = devtool_options("cmake_configure", &args, 2, &["generator", "defines"])?;
let generator = devtool_opt_str(
"cmake_configure",
"generator",
options,
"generator",
tf_plain,
)?;
let defines = match options.and_then(|o| o.get("defines")) {
Some(v) => devtool_defines("cmake_configure", v)?,
None => Vec::new(),
};
let source_dir = devtool_dir("cmake_configure", source.as_deref())?;
let cwd = devtool_cwd("cmake_configure")?;
let build_dir = workspace_path("cmake_configure", &build, &cwd)?;
let mut argv: Vec<String> = vec![
"-S".to_string(),
source_dir.display().to_string(),
"-B".to_string(),
build_dir.display().to_string(),
];
if let Some(g) = generator {
argv.push("-G".to_string());
argv.push(g);
}
argv.extend(defines);
std::fs::create_dir_all(&build_dir)
.map_err(|e| crate::safety::fs_error("cmake_configure", &build_dir, &e))?;
let refs: Vec<&str> = argv.iter().map(String::as_str).collect();
let run = finished(
"cmake_configure",
tool_run(
"cmake_configure",
"cmake",
None,
&refs,
Keep::Tail(TOOL_KEEP_TAIL),
TOOL_LONG_DEADLINE_SECS,
)?,
)?;
let mut rec = run_fields(run, "stdout", "stderr");
rec.insert(
"source_dir".to_string(),
Value::Str(source_dir.display().to_string()),
);
rec.insert(
"build_dir".to_string(),
Value::Str(build_dir.display().to_string()),
);
Ok(Value::Record(rec))
}
/// cmake — Build a configured CMake build directory.
/// Usage: cmake_build(build_dir) or cmake_build(build_dir, {target?, config?, jobs?})
/// The build directory must exist and lie inside the workspace. Options are
/// one argument each and may not start with '-'. Runs the project's build
/// steps, so its effect is Exec. A nonzero exit is E_TOOL_FAILED.
fn bi_cmake_build(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("cmake_build", &input)?;
let build = k8s_arg("cmake_build", "build directory", &args, 0, tf_plain)?
.unwrap_or_else(|| "build".to_string());
let options = devtool_options("cmake_build", &args, 1, &["target", "config", "jobs"])?;
let target = devtool_opt_str("cmake_build", "target", options, "target", tf_plain)?;
let config = devtool_opt_str("cmake_build", "config", options, "config", tf_plain)?;
let jobs = devtool_opt_int("cmake_build", "jobs", options, "jobs", 1, DEVTOOL_MAX_JOBS)?;
let dir = devtool_dir("cmake_build", Some(build.as_str()))?;
let mut argv: Vec<String> = vec!["--build".to_string(), dir.display().to_string()];
if let Some(t) = &target {
argv.push("--target".to_string());
argv.push(t.clone());
}
if let Some(c) = &config {
argv.push("--config".to_string());
argv.push(c.clone());
}
if let Some(j) = jobs {
argv.push("-j".to_string());
argv.push(j.to_string());
}
let refs: Vec<&str> = argv.iter().map(String::as_str).collect();
let run = finished(
"cmake_build",
tool_run(
"cmake_build",
"cmake",
None,
&refs,
Keep::Tail(TOOL_KEEP_TAIL),
TOOL_LONG_DEADLINE_SECS,
)?,
)?;
let mut rec = run_fields(run, "stdout", "stderr");
rec.insert(
"build_dir".to_string(),
Value::Str(dir.display().to_string()),
);
Ok(Value::Record(rec))
}
// ---------------------------------------------------------------------------
// Lint, format and search builtins: the checks and bounded results they share
// ---------------------------------------------------------------------------
/// The most results one search returns. A search with more is refused with
/// E_BUDGET_EXCEEDED, because a list cut at a cap reads as the whole answer.
const SEARCH_MAX_RESULTS: usize = 1000;
/// The most bytes of one text file a content search reads.
const SEARCH_MAX_FILE: u64 = 64 * 1024 * 1024;
/// The leading bytes a content search inspects for a NUL, as `grep -I` does.
const SEARCH_BINARY_PROBE: u64 = 8000;
/// A search pattern or other search text: non-empty, no NUL byte. It reaches
/// the tool as `--regexp=` or as a value, so a leading '-' is data, not a flag.
fn search_text_check(builtin: &str, what: &str, value: &str) -> Result<String> {
if value.is_empty() || value.contains('\0') {
return Err(crate::safety::arg_err(format!(
"{builtin}: {what} must be a non-empty string with no NUL byte"
)));
}
Ok(value.to_string())
}
/// The refusal for a search whose results would pass the cap.
fn search_budget(builtin: &str) -> anyhow::Error {
crate::safety::budget_exceeded(
builtin,
format!("more than {SEARCH_MAX_RESULTS} results"),
"narrow the pattern or the path so that fewer results match",
)
}
/// Found paths or lines as the Array a search returns, refused past the cap.
fn search_list(builtin: &str, found: Vec<String>) -> Result<Value> {
if found.len() > SEARCH_MAX_RESULTS {
return Err(search_budget(builtin));
}
Ok(Value::Array(found.into_iter().map(Value::Str).collect()))
}
/// The stdout of a tool whose exit 1 is an answer rather than a failure: ripgrep
/// (and rga, which runs it) exit 1 for "no matches", mypy for "type errors".
/// Any other nonzero exit is E_TOOL_FAILED with the stderr tail.
fn stdout_with_exit1(builtin: &str, mut run: ToolRun) -> Result<String> {
if run.code == Some(1) {
run.success = true;
}
tool_text(builtin, run)
}
/// A tool whose exit 0 and exit `answer` both print a JSON report, which is the
/// answer. Any other exit is E_TOOL_FAILED, and output that is not JSON is too.
fn findings_json(builtin: &str, mut run: ToolRun, answer: i32) -> Result<Value> {
if run.code == Some(answer) {
run.success = true;
}
let command = run.command.clone();
let code = run.code;
let stderr = run.stderr.clone();
let text = tool_text(builtin, run)?;
serde_json::from_str::<serde_json::Value>(&text)
.map(json_to_value)
.map_err(|e| {
let detail = if stderr.is_empty() {
format!("printed output that is not JSON ({e})")
} else {
stderr
};
crate::safety::tool_failed(builtin, &command, code, &detail)
})
}
/// Writes `bytes` to a new file, in a new directory under the system temporary
/// directory, for a tool that reads its input from a file rather than a pipe.
/// The caller removes the directory with [`scratch_remove`].
fn scratch_file(builtin: &str, file_name: &str, bytes: &[u8]) -> Result<std::path::PathBuf> {
let dir = std::env::temp_dir().join(format!("ae_{builtin}_{}", uuid::Uuid::new_v4().simple()));
std::fs::create_dir(&dir).map_err(|e| crate::safety::fs_error(builtin, &dir, &e))?;
let file = dir.join(file_name);
if let Err(e) = std::fs::write(&file, bytes) {
scratch_remove(&file);
return Err(crate::safety::fs_error(builtin, &file, &e));
}
Ok(file)
}
/// Removes the directory that [`scratch_file`] made for `file`.
fn scratch_remove(file: &std::path::Path) {
if let Some(dir) = file.parent() {
let _ = std::fs::remove_dir_all(dir);
}
}
/// An fd entry kind: one of fd's type letters.
fn fd_kind_check(builtin: &str, what: &str, value: &str) -> Result<String> {
match value {
"f" | "d" | "l" | "x" | "e" | "s" | "p" => Ok(value.to_string()),
_ => Err(crate::safety::arg_err(format!(
"{builtin}: {what} is one of f, d, l, x, e, s, p (got {value:?})"
))),
}
}
/// A file extension for fd: letters, digits and `_+-`, not starting with '-'.
fn fd_ext_check(builtin: &str, what: &str, value: &str) -> Result<String> {
let well_formed = !value.is_empty()
&& value.len() <= 32
&& !value.starts_with('-')
&& value
.chars()
.all(|c| c.is_ascii_alphanumeric() || "_+-".contains(c));
if well_formed {
Ok(value.to_string())
} else {
Err(crate::safety::arg_err(format!(
"{builtin}: {what} is an extension such as rs (letters, digits and _+- only), got {value:?}"
)))
}
}
/// The text of one file that a content search reads. A file with a NUL in its
/// first bytes is binary and is skipped, as `grep -I` skips it. A text file past
/// the size limit is refused rather than read in part.
fn search_read_text(builtin: &str, path: &std::path::Path, shown: &str) -> Result<Option<String>> {
use std::io::Read;
let mut file =
std::fs::File::open(path).map_err(|e| crate::safety::fs_error(builtin, shown, &e))?;
let mut bytes = Vec::new();
(&mut file)
.take(SEARCH_BINARY_PROBE)
.read_to_end(&mut bytes)
.map_err(|e| crate::safety::fs_error(builtin, shown, &e))?;
if bytes.contains(&0) {
return Ok(None);
}
(&mut file)
.take(SEARCH_MAX_FILE + 1 - bytes.len() as u64)
.read_to_end(&mut bytes)
.map_err(|e| crate::safety::fs_error(builtin, shown, &e))?;
if bytes.len() as u64 > SEARCH_MAX_FILE {
return Err(crate::safety::budget_exceeded(
builtin,
format!("`{shown}` is larger than {} MiB", SEARCH_MAX_FILE >> 20),
"search a narrower directory",
));
}
Ok(Some(String::from_utf8_lossy(&bytes).into_owned()))
}
fn search_pattern_builtin(builtin: &str, args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input(builtin, &input)?;
k8s_max_args(builtin, &args, 1)?;
let pattern = k8s_need(builtin, "a regular expression", &args, 0, search_text_check)?;
let found = search_grep(builtin, &pattern)?;
search_list(builtin, found)
}
/// ninja — Fast build system.
/// Usage: ninja_build() or ninja_build({dir?, target?, jobs?})
fn bi_ninja_build(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "ninja_build";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 1)?;
let mut dir = None;
let mut options = None;
match args.first() {
None => {}
Some(Value::Str(d)) => dir = Some(pkg_plain(B, "dir", d)?),
Some(_) => options = devtool_options(B, &args, 0, &["dir", "target", "jobs"])?,
}
if dir.is_none() {
dir = devtool_opt_str(B, "dir", options, "dir", pkg_plain)?;
}
let target = devtool_opt_str(B, "target", options, "target", pkg_plain)?;
let jobs = devtool_opt_int(B, "jobs", options, "jobs", 1, DEVTOOL_MAX_JOBS)?;
let workdir = devtool_dir(B, dir.as_deref())?;
let mut argv: Vec<String> = Vec::new();
if let Some(j) = jobs {
argv.push(format!("-j{j}"));
}
if let Some(t) = target {
argv.push(t);
}
let refs: Vec<&str> = argv.iter().map(String::as_str).collect();
let run = tool_run(
B,
"ninja",
Some(workdir.as_path()),
&refs,
Keep::Tail(TOOL_KEEP_TAIL),
TOOL_LONG_DEADLINE_SECS,
)?;
pkg_done(B, run, Vec::new())
}
/// netstat — Display network connections and socket information (cross-platform).
/// Usage: netstat() or netstat({proto?, state?, port?})
fn bi_netstat_info(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let flags = if cfg!(target_os = "windows") {
vec!["-an".to_string()]
} else {
vec!["-tuln".to_string()]
};
let mut cmd = crate::safety::child_command("netstat");
cmd.args(&flags);
let out = bounded_cmd_output("netstat", "netstat", cmd, PROBE_DEADLINE_SECS)?;
if !out.status.success() {
return Err(crate::safety::tool_failed(
"netstat",
"netstat",
out.status.code(),
&String::from_utf8_lossy(&out.stderr),
));
}
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
// Parse netstat output into structured records
let filter_proto = match args.first() {
Some(Value::Record(r)) => r.get("proto").and_then(|v| {
if let Value::Str(s) = v {
Some(s.clone())
} else {
None
}
}),
_ => None,
};
let filter_state = match args.first() {
Some(Value::Record(r)) => r.get("state").and_then(|v| {
if let Value::Str(s) = v {
Some(s.to_uppercase())
} else {
None
}
}),
_ => None,
};
let filter_port = match args.first() {
Some(Value::Record(r)) => r.get("port").and_then(|v| {
if let Value::Int(n) = v {
Some(*n)
} else {
None
}
}),
_ => None,
};
let mut connections = Vec::new();
for line in stdout.lines().skip(2) {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 4 {
continue;
}
let proto = parts[0].to_string();
let local_addr = if parts.len() > 3 {
parts[3].to_string()
} else {
parts[1].to_string()
};
let foreign_addr = if parts.len() > 4 {
parts[4].to_string()
} else {
parts[2].to_string()
};
let state = if parts.len() > 5 {
parts[5].to_string()
} else {
String::new()
};
// Apply filters
if let Some(ref fp) = filter_proto {
if !proto.to_lowercase().contains(&fp.to_lowercase()) {
continue;
}
}
if let Some(ref fs) = filter_state {
if !state.to_uppercase().contains(fs) {
continue;
}
}
if let Some(fp) = filter_port {
let port_str = format!(":{}", fp);
if !local_addr.contains(&port_str) && !foreign_addr.contains(&port_str) {
continue;
}
}
let mut rec = BTreeMap::new();
rec.insert("proto".to_string(), Value::Str(proto));
rec.insert("local_address".to_string(), Value::Str(local_addr));
rec.insert("foreign_address".to_string(), Value::Str(foreign_addr));
if !state.is_empty() {
rec.insert("state".to_string(), Value::Str(state));
}
connections.push(Value::Record(rec));
}
Ok(Value::Array(connections))
}
/// nmap — Network port scanning.
/// Usage: nmap_scan(host) or nmap_scan(host, {ports?, flags?}). ports is a list
/// such as "22,80,1000-1100" with every port in 1..=65535. flags is a
/// whitespace-separated list drawn from NMAP_FLAGS; any other flag is refused by
/// policy, because nmap's other options run scripts or write files. The host may
/// not begin with '-'. Returns {host, ports}, one Record per port, with its
/// state and service. A nonzero exit is E_TOOL_FAILED; a missing nmap is E_TOOL_MISSING.
fn bi_nmap_scan(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
net_refuse_in_transaction("nmap_scan")?;
k8s_no_input("nmap_scan", &input)?;
k8s_max_args("nmap_scan", &args, 2)?;
let host = k8s_need("nmap_scan", "a host", &args, 0, net_target_host)?;
let options = devtool_options("nmap_scan", &args, 1, &["ports", "flags"])?;
let ports = devtool_opt_str("nmap_scan", "ports", options, "ports", nmap_ports)?;
let flags = devtool_opt_str("nmap_scan", "flags", options, "flags", nmap_flags)?;
let mut argv: Vec<String> = vec!["-oX".to_string(), "-".to_string()];
if let Some(p) = ports {
argv.push("-p".to_string());
argv.push(p);
}
if let Some(f) = flags {
argv.extend(f.split_whitespace().map(str::to_string));
}
argv.push(host.clone());
let refs: Vec<&str> = argv.iter().map(String::as_str).collect();
let run = tool_run(
"nmap_scan",
"nmap",
None,
&refs,
Keep::Head(TOOL_MAX_TEXT),
TOOL_LONG_DEADLINE_SECS,
)?;
let xml = tool_text("nmap_scan", run)?;
let mut port_rows: Vec<Value> = Vec::new();
for line in xml.lines() {
let line = line.trim();
if line.starts_with("<port ") {
let mut rec = BTreeMap::new();
if let Some(portid) = extract_xml_attr(line, "portid") {
rec.insert(
"port".to_string(),
portid.parse::<i64>().map_or(Value::Null, Value::Int),
);
}
if let Some(proto) = extract_xml_attr(line, "protocol") {
rec.insert("protocol".to_string(), Value::Str(proto));
}
port_rows.push(Value::Record(rec));
}
if line.starts_with("<state ") {
if let Some(Value::Record(rec)) = port_rows.last_mut() {
if let Some(state) = extract_xml_attr(line, "state") {
rec.insert("state".to_string(), Value::Str(state));
}
if let Some(reason) = extract_xml_attr(line, "reason") {
rec.insert("reason".to_string(), Value::Str(reason));
}
}
}
if line.starts_with("<service ") {
if let Some(Value::Record(rec)) = port_rows.last_mut() {
if let Some(name) = extract_xml_attr(line, "name") {
rec.insert("service".to_string(), Value::Str(name));
}
}
}
}
let mut result = BTreeMap::new();
result.insert("host".to_string(), Value::Str(host));
result.insert("ports".to_string(), Value::Array(port_rows));
Ok(Value::Record(result))
}
/// nmap — Quick scan of the common ports, open ones only.
/// Usage: nmap_quick(host). The host may not begin with '-'. Returns {host,
/// ports, scan_type}. A nonzero exit is E_TOOL_FAILED; a missing nmap is E_TOOL_MISSING.
fn bi_nmap_quick(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
net_refuse_in_transaction("nmap_quick")?;
k8s_no_input("nmap_quick", &input)?;
k8s_max_args("nmap_quick", &args, 1)?;
let host = k8s_need("nmap_quick", "a host", &args, 0, net_target_host)?;
let run = tool_run(
"nmap_quick",
"nmap",
None,
&["-F", "--open", host.as_str()],
Keep::Head(TOOL_MAX_TEXT),
TOOL_LONG_DEADLINE_SECS,
)?;
let stdout = tool_text("nmap_quick", run)?;
let mut ports = Vec::new();
for line in stdout.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 3 && parts[0].contains('/') {
let port_proto: Vec<&str> = parts[0].split('/').collect();
let mut rec = BTreeMap::new();
rec.insert(
"port".to_string(),
port_proto[0].parse::<i64>().map_or(Value::Null, Value::Int),
);
rec.insert(
"protocol".to_string(),
Value::Str(port_proto.get(1).unwrap_or(&"").to_string()),
);
rec.insert("state".to_string(), Value::Str(parts[1].to_string()));
rec.insert("service".to_string(), Value::Str(parts[2].to_string()));
ports.push(Value::Record(rec));
}
}
let mut result = BTreeMap::new();
result.insert("host".to_string(), Value::Str(host));
result.insert("ports".to_string(), Value::Array(ports));
result.insert(
"scan_type".to_string(),
Value::Str("quick (-F --open)".to_string()),
);
Ok(Value::Record(result))
}
/// Helper: extract an XML attribute value from a tag line.
fn extract_xml_attr(line: &str, attr: &str) -> Option<String> {
let pattern = format!("{}=\"", attr);
if let Some(start) = line.find(&pattern) {
let val_start = start + pattern.len();
if let Some(end) = line[val_start..].find('"') {
return Some(line[val_start..val_start + end].to_string());
}
}
None
}
// ============================================================
// CLI Tool Wrappers — Tools 51-150 (indices 988-1103)
// ============================================================
// --- Dead-code activations (988-994) ---
// bi_file_type (988) — already defined at ~L32793
// bi_dd_copy (989) — already defined at ~L33373
// bi_lsblk (990) — already defined at ~L32948
// bi_blkid (991) — already defined at ~L32983
// bi_strace_cmd (992) — already defined at ~L33146
// bi_mount_info (993) — already defined at ~L33569
// bi_top_snapshot (994) — already defined at ~L33270
// --- tmux (995-998) ---
/// tmux new-session — Create a detached tmux session with a shell in it.
/// Usage: tmux_new(session_name)
/// The name is 1-64 letters, digits, '-' or '_'. The session runs the default
/// shell and nothing else: tmux takes a command as one string for its shell,
/// which is `sh -c` by another name, so to start a program use tmux_send.
/// A name already in use is E_TOOL_FAILED.
fn bi_tmux_new(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("tmux_new", &input)?;
if args.len() > 1 {
return Err(crate::safety::arg_err(format!(
"tmux_new takes a session name only; {} more argument(s) given. Start the program inside the session with tmux_send, which is approval-gated.",
args.len() - 1
)));
}
let name = k8s_need("tmux_new", "session name", &args, 0, devtool_session)?;
// tmux_new is listed as self-guarded, so the central gate does not run for
// it. This is the guard it relies on instead: creating a session is a
// process effect and asks for approval in agent mode.
crate::safety::guard(crate::safety::GuardCtx {
builtin: "tmux_new",
effect: crate::safety::Effect::Process,
what: "process",
targets: vec![name.clone()],
blast_radius: serde_json::json!({ "sessions": 1 }),
reversible: true,
fs_paths: false,
})?;
let run = finished(
"tmux_new",
tool_run(
"tmux_new",
"tmux",
None,
&["new-session", "-d", "-s", name.as_str()],
Keep::Tail(TOOL_KEEP_TAIL),
TOOL_DEADLINE_SECS,
)?,
)?;
let mut rec = run_fields(run, "stdout", "stderr");
rec.insert("session".to_string(), Value::Str(name));
Ok(Value::Record(rec))
}
/// tmux list-sessions — List active tmux sessions.
/// Whether tmux's failure text means there is no server, and so no sessions.
/// "no server running" is the answer. "error connecting to <socket> (No such
/// file or directory)" is the same state when the socket file is absent. Any
/// other connect error, such as a permission denial on the socket, is not.
fn tmux_no_server(stderr: &str) -> bool {
stderr.contains("no server running")
|| (stderr.contains("error connecting to") && stderr.contains("No such file or directory"))
}
/// One `name:windows:attached` line of `tmux list-sessions -F`. A line that
/// does not have that shape is refused, not read with a zero window count.
fn tmux_session_record(line: &str) -> Result<Value> {
let parts: Vec<&str> = line.splitn(3, ':').collect();
let (Some(name), Some(windows), Some(attached)) = (parts.first(), parts.get(1), parts.get(2))
else {
return Err(tmux_unparsed());
};
let windows: i64 = windows.parse().map_err(|_| tmux_unparsed())?;
let attached = match *attached {
"0" => false,
"1" => true,
_ => return Err(tmux_unparsed()),
};
let mut rec = BTreeMap::new();
rec.insert("name".to_string(), Value::Str(name.to_string()));
rec.insert("windows".to_string(), Value::Int(windows));
rec.insert("attached".to_string(), Value::Bool(attached));
Ok(Value::Record(rec))
}
fn tmux_unparsed() -> anyhow::Error {
anyhow::Error::new(crate::safety::SafetyError {
code: crate::safety::ErrorCode::ToolFailed,
message:
"tmux_list: the output of `tmux list-sessions` is not in a form this builtin reads"
.to_string(),
builtin: "tmux_list".to_string(),
hint: "`tmux` ran, but its session list does not match the format this builtin asks for"
.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: "`tmux list-sessions` output this builtin reads".to_string(),
got: "unreadable output".to_string(),
})
}
fn bi_tmux_list(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut cmd = crate::safety::child_command("tmux");
cmd.args([
"list-sessions",
"-F",
"#{session_name}:#{session_windows}:#{session_attached}",
]);
let output = bounded_cmd_output("tmux_list", "tmux", cmd, TOOL_DEADLINE_SECS);
match output {
Ok(o) if o.status.success() => {
let stdout = String::from_utf8_lossy(&o.stdout);
let sessions: Vec<Value> = stdout
.lines()
.filter(|l| !l.is_empty())
.map(tmux_session_record)
.collect::<Result<_>>()?;
Ok(Value::Array(sessions))
}
// tmux's stderr used to come back as the successful result: with no
// server running, `tmux_list()` answered the String "error connecting
// to /tmp/tmux-1001/default (No such file or directory)" at exit 0.
// No server means no sessions; anything else is a failure. Any
// "error connecting to" used to be read as no server, which also hid a
// permission error on the socket.
Ok(o) => {
let err = String::from_utf8_lossy(&o.stderr);
if tmux_no_server(&err) {
return Ok(Value::Array(vec![]));
}
Err(crate::safety::tool_failed(
"tmux_list",
"tmux list-sessions",
o.status.code(),
&err,
))
}
Err(e) => Err(crate::safety::tool_missing(
"tmux_list",
"tmux",
&e.to_string(),
)),
}
}
/// tmux attach — Attach this terminal to a tmux session until the user detaches.
/// Usage: tmux_attach(session_name)
/// Needs a terminal on stdin and stdout and is refused as E_NO_UI without one,
/// and in agent mode. Its effect is Process, so agent mode would ask first.
fn bi_tmux_attach(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("tmux_attach", &input)?;
let name = k8s_need("tmux_attach", "session name", &args, 0, devtool_session)?;
devtool_terminal("tmux_attach")?;
devtool_attach(
"tmux_attach",
"tmux",
&["attach-session", "-t", &exact_tmux_target(&name)],
"tmux attach-session",
)?;
let mut rec = BTreeMap::new();
rec.insert("session".to_string(), Value::Str(name));
rec.insert("success".to_string(), Value::Bool(true));
Ok(Value::Record(rec))
}
/// tmux send-keys — Type a line into a session the caller named, then press Enter.
/// Usage: tmux_send(target, keys)
/// This is execution in another process's shell, so it is approval-gated. The
/// keys are one line with no control characters and may not start with '-'.
/// A session that does not exist is E_TOOL_FAILED.
fn bi_tmux_send(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("tmux_send", &input)?;
let target = k8s_need("tmux_send", "target session", &args, 0, devtool_target)?;
let keys = k8s_need("tmux_send", "keys to send", &args, 1, devtool_keys)?;
crate::safety::guard_exec("tmux_send", &keys)?;
let run = finished(
"tmux_send",
tool_run(
"tmux_send",
"tmux",
None,
&[
"send-keys",
"-t",
&exact_tmux_pane(&target),
keys.as_str(),
"Enter",
],
Keep::Tail(TOOL_KEEP_TAIL),
TOOL_DEADLINE_SECS,
)?,
)?;
let mut rec = run_fields(run, "stdout", "stderr");
rec.insert("target".to_string(), Value::Str(target));
rec.insert("keys".to_string(), Value::Str(keys));
Ok(Value::Record(rec))
}
/// A tmux session target matched exactly. tmux otherwise resolves `-t work`
/// to a session whose name starts with "work" when none is called "work", so
/// a kill or attach would reach another session.
fn exact_tmux_target(name: &str) -> String {
format!("={name}")
}
/// The pane-target form of [`exact_tmux_target`], for send-keys: tmux reads
/// a bare `=work` there as a pane name and finds none, so a session alone is
/// written `=work:` (its current window), and `work:1` becomes `=work:1`.
fn exact_tmux_pane(target: &str) -> String {
if target.contains(':') {
format!("={target}")
} else {
format!("={target}:")
}
}
/// tmux kill-session — End a session that tmux_new started, and what runs in it.
/// Usage: tmux_kill(session_name)
/// The name is 1-64 letters, digits, '-' or '_', as tmux_new takes it. It runs
/// no command, so it is process control, not exec. A session that does not
/// exist is E_TOOL_FAILED.
fn bi_tmux_kill(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("tmux_kill", &input)?;
if args.len() > 1 {
return Err(crate::safety::arg_err(format!(
"tmux_kill takes a session name only; {} more argument(s) given.",
args.len() - 1
)));
}
let name = k8s_need("tmux_kill", "session name", &args, 0, devtool_session)?;
let run = finished(
"tmux_kill",
tool_run(
"tmux_kill",
"tmux",
None,
&["kill-session", "-t", &exact_tmux_target(&name)],
Keep::Tail(TOOL_KEEP_TAIL),
TOOL_DEADLINE_SECS,
)?,
)?;
let mut rec = run_fields(run, "stdout", "stderr");
rec.insert("session".to_string(), Value::Str(name));
Ok(Value::Record(rec))
}
// --- screen (999-1001) ---
/// screen — Start a detached GNU screen session with a shell in it.
/// Usage: screen_new(session_name)
/// The name is 1-64 letters, digits, '-' or '_'. A failure is E_TOOL_FAILED.
fn bi_screen_new(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("screen_new", &input)?;
let name = k8s_need("screen_new", "session name", &args, 0, devtool_session)?;
let run = finished(
"screen_new",
tool_run(
"screen_new",
"screen",
None,
&["-dmS", name.as_str()],
Keep::Tail(TOOL_KEEP_TAIL),
TOOL_DEADLINE_SECS,
)?,
)?;
let mut rec = run_fields(run, "stdout", "stderr");
rec.insert("session".to_string(), Value::Str(name));
Ok(Value::Record(rec))
}
/// screen list — List screen sessions.
fn bi_screen_list(_args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut cmd = crate::safety::child_command("screen");
cmd.args(["-ls"]);
let output = bounded_cmd_output("screen_list", "screen", cmd, PROBE_DEADLINE_SECS)?;
screen_sessions(
output.status.success(),
output.status.code(),
&String::from_utf8_lossy(&output.stdout),
&String::from_utf8_lossy(&output.stderr),
)
}
/// What `screen -ls` answers, from its exit status and both streams.
///
/// GNU screen exits non-zero for two things that are answers, not failures:
/// no sessions at all ("No Sockets found in <dir>."), and a listing whose
/// sessions are all dead. Both are answered from what it printed: the session
/// lines, or none. Any other non-zero exit is a failure, such as a socket
/// directory it cannot read. That used to come back as an empty list, which
/// reads as "no sessions". I have not confirmed the exact exit codes on a
/// host, so the code keys on the output rather than on a number.
fn screen_sessions(success: bool, code: Option<i32>, stdout: &str, stderr: &str) -> Result<Value> {
let sessions: Vec<Value> = stdout
.lines()
.filter(|l| l.contains('('))
.map(|line| {
let trimmed = line.trim();
let mut rec = BTreeMap::new();
rec.insert("line".to_string(), Value::Str(trimmed.to_string()));
if let Some(name_part) = trimmed.split_whitespace().next() {
rec.insert("id".to_string(), Value::Str(name_part.to_string()));
}
Value::Record(rec)
})
.collect();
let says_none = stdout.contains("No Sockets found") || stderr.contains("No Sockets found");
if success || !sessions.is_empty() || says_none {
return Ok(Value::Array(sessions));
}
Err(crate::safety::tool_failed(
"screen_list",
"screen",
code,
stderr,
))
}
/// screen — Attach this terminal to a screen session until the user detaches.
/// Usage: screen_attach(session_name)
/// Needs a terminal on stdin and stdout and is refused as E_NO_UI without one,
/// and in agent mode. Its effect is Process, so agent mode would ask first.
fn bi_screen_attach(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("screen_attach", &input)?;
let name = k8s_need("screen_attach", "session name", &args, 0, devtool_session)?;
devtool_terminal("screen_attach")?;
devtool_attach(
"screen_attach",
"screen",
&["-r", name.as_str()],
"screen -r",
)?;
let mut rec = BTreeMap::new();
rec.insert("session".to_string(), Value::Str(name));
rec.insert("success".to_string(), Value::Bool(true));
Ok(Value::Record(rec))
}
// --- timeout (1002) ---
/// timeout — Run one program with a time limit, and return its result.
/// Usage: timeout_cmd(seconds, command). seconds is 1..=3600. The command is split
/// on whitespace into a program and its arguments and run directly, with no shell;
/// the program may not begin with '-'. Returns {command, exit_code, stdout,
/// stderr, stdout_truncated}. The exit code is the program's and is data. A
/// program still running at the limit is killed and the call is E_BUDGET_EXCEEDED.
fn bi_timeout_cmd(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("timeout_cmd", &input)?;
k8s_max_args("timeout_cmd", &args, 2)?;
let seconds = match args.first() {
Some(Value::Int(n)) if (1..=3600).contains(n) => *n as u64,
_ => {
return Err(crate::safety::arg_err(
"timeout_cmd requires seconds as an Int 1..=3600 first",
))
}
};
let command = match args.get(1) {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(crate::safety::arg_err(
"timeout_cmd requires a command string second",
))
}
};
let words = argv_words("timeout_cmd", &command)?;
crate::safety::guard_exec("timeout", &command)?;
let argv: Vec<&str> = words.iter().skip(1).map(String::as_str).collect();
let run = tool_run_stderr(
"timeout_cmd",
&words[0],
None,
&argv,
Keep::Tail(TOOL_KEEP_TAIL),
TOOL_KEEP_TAIL,
seconds,
)?;
let mut rec = BTreeMap::new();
rec.insert("command".to_string(), Value::Str(command));
rec.insert("exit_code".to_string(), Value::Int(exit_code_of(&run)));
rec.insert("stdout".to_string(), Value::Str(run.stdout.clone()));
rec.insert("stderr".to_string(), Value::Str(run.stderr.clone()));
rec.insert(
"stdout_truncated".to_string(),
Value::Bool(run.stdout_truncated),
);
Ok(Value::Record(rec))
}
// --- pkill (1003) ---
/// The process-name pattern `pkill` and `pgrep` take: a literal name prefix.
///
/// Both used to hand the pattern to an OS tool with different rules. On Unix it
/// was a pkill regex matched anywhere in the name, so `.` matched every process;
/// on Windows it became `taskkill /IM pattern*`, so an empty pattern was `*`.
/// Wildcards are refused rather than escaped, because a caller writing one
/// meant it, and a literal match would quietly do something else.
fn proc_name_pattern(builtin: &str, args: &[Value], input: &Option<Value>) -> Result<String> {
refuse_piped(builtin, input)?;
k8s_max_args(builtin, args, 1)?;
match args.first() {
Some(Value::Str(s))
if !s.trim().is_empty() && !s.chars().any(|c| c.is_control() || "*?[]".contains(c)) =>
{
Ok(s.clone())
}
other => Err(crate::safety::bad_arg(
builtin,
"a process name prefix: a non-empty String with no wildcard or control character",
other.map(|v| v.type_name()).unwrap_or("nothing"),
)),
}
}
/// Processes whose name begins with `pattern`, sorted by pid. Names compare
/// without regard to case on Windows, where the file system does the same.
fn proc_name_matches(pattern: &str) -> Vec<(u32, String, String)> {
let mut sys = System::new();
sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
let fold = |s: &str| {
if cfg!(windows) {
s.to_lowercase()
} else {
s.to_string()
}
};
let want = fold(pattern);
let mut found: Vec<(u32, String, String)> = sys
.processes()
.iter()
// On Linux each thread is listed too, under its process name; a
// thread is not a process to report or to kill.
.filter(|(_, p)| p.thread_kind().is_none())
.filter_map(|(pid, p)| {
let name = p.name().to_string_lossy().to_string();
if !fold(&name).starts_with(&want) {
return None;
}
let command = p
.cmd()
.iter()
.map(|a| a.to_string_lossy().to_string())
.collect::<Vec<_>>()
.join(" ");
Some((pid.as_u32(), name, command))
})
.collect();
found.sort_by_key(|(pid, _, _)| *pid);
found
}
/// pkill — Kill every process whose name begins with a prefix.
/// Usage: pkill(prefix). The prefix is literal; wildcards are refused. This shell
/// and pid 0 and 1 are never killed. Each match is killed forcibly (SIGKILL,
/// or TerminateProcess on Windows). The result is {pattern, killed: [pid]};
/// no match is an empty list. A match that could not be killed is
/// E_TOOL_FAILED, naming the pids that were killed before it.
///
/// It used to report `success: false` as a value when nothing was killed or the
/// kill was refused, and it ran a different matcher on each OS.
fn bi_pkill(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let pattern = proc_name_pattern("pkill", &args, &input)?;
let me = std::process::id();
let targets: Vec<(u32, String)> = proc_name_matches(&pattern)
.into_iter()
.filter(|(pid, _, _)| *pid > 1 && *pid != me)
.map(|(pid, name, _)| (pid, name))
.collect();
let mut sys = System::new();
let mut killed = Vec::new();
let mut failed = Vec::new();
for (pid, name) in targets {
let spid = sysinfo::Pid::from_u32(pid);
sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[spid]), true);
match sys.process(spid) {
// Exited between the listing and now: nothing left to kill.
None => {}
Some(p) if p.kill() => killed.push(pid),
Some(_) => failed.push(format!("{pid} ({name})")),
}
}
if !failed.is_empty() {
let done: Vec<String> = killed.iter().map(u32::to_string).collect();
return Err(crate::safety::tool_failed(
"pkill",
&format!("kill processes named {pattern}*"),
None,
&format!(
"could not kill {}; killed [{}]",
failed.join(", "),
done.join(", ")
),
));
}
let mut rec = BTreeMap::new();
rec.insert("pattern".to_string(), Value::Str(pattern));
rec.insert(
"killed".to_string(),
Value::Array(
killed
.into_iter()
.map(|p| Value::Int(i64::from(p)))
.collect(),
),
);
Ok(Value::Record(rec))
}
// --- pgrep (1004) ---
/// pgrep — Find processes whose name begins with a prefix.
/// Usage: pgrep(prefix). The prefix is literal; wildcards are refused. The
/// result is [{pid, name, command}] sorted by pid, empty when nothing matches.
///
/// The pid used to be a String on Windows and an Int elsewhere, and a failed
/// listing came back as an empty list.
fn bi_pgrep(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let pattern = proc_name_pattern("pgrep", &args, &input)?;
let procs = proc_name_matches(&pattern)
.into_iter()
.map(|(pid, name, command)| {
let mut rec = BTreeMap::new();
rec.insert("pid".to_string(), Value::Int(i64::from(pid)));
rec.insert("name".to_string(), Value::Str(name));
rec.insert("command".to_string(), Value::Str(command));
Value::Record(rec)
})
.collect();
Ok(Value::Array(procs))
}
// --- lnav (1005) ---
/// lnav — Open one log file in the lnav viewer.
/// Usage: lnav_open(path). The file must exist, and its path may not begin with
/// '-'. lnav is interactive, so it needs a terminal on stdin and stdout and a
/// person at it. Without both, or in agent mode, the call is E_NO_UI before
/// anything is started. With a terminal the viewer runs until the person quits,
/// and the result is {path}.
fn bi_lnav_open(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("lnav_open", &input)?;
k8s_max_args("lnav_open", &args, 1)?;
let path = k8s_need("lnav_open", "a log file path", &args, 0, tf_plain)?;
if !std::path::Path::new(&path).is_file() {
return Err(crate::safety::not_found("lnav_open", "log file", &path));
}
devtool_terminal("lnav_open")?;
devtool_attach("lnav_open", "lnav", &[path.as_str()], "lnav")?;
let mut rec = BTreeMap::new();
rec.insert("path".to_string(), Value::Str(path));
Ok(Value::Record(rec))
}
// --- multitail (1006) ---
/// multitail — Tail several files at once in the multitail viewer.
/// Usage: multitail_open(file, ...), one to 16 existing files, none beginning
/// with '-'. multitail is interactive, so it needs a terminal on stdin and stdout
/// and a person at it. Without both, or in agent mode, the call is E_NO_UI before
/// anything is started. With a terminal the viewer runs until the person quits,
/// and the result is {files}.
fn bi_multitail_open(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("multitail_open", &input)?;
if args.is_empty() {
return Err(crate::safety::arg_err(
"multitail_open requires at least one file path",
));
}
if args.len() > MULTITAIL_MAX_FILES {
return Err(crate::safety::budget_exceeded(
"multitail_open",
format!("{} files; the limit is {MULTITAIL_MAX_FILES}", args.len()),
"open fewer files at once",
));
}
let mut files: Vec<String> = Vec::with_capacity(args.len());
for a in &args {
let Value::Str(s) = a else {
return Err(crate::safety::bad_arg(
"multitail_open",
"a file path String",
a.type_name(),
));
};
let f = tf_plain("multitail_open", "a file path", s)?;
if !std::path::Path::new(&f).is_file() {
return Err(crate::safety::not_found("multitail_open", "log file", &f));
}
files.push(f);
}
devtool_terminal("multitail_open")?;
let refs: Vec<&str> = files.iter().map(String::as_str).collect();
devtool_attach("multitail_open", "multitail", &refs, "multitail")?;
let mut rec = BTreeMap::new();
rec.insert(
"files".to_string(),
Value::Array(files.iter().map(|f| Value::Str(f.clone())).collect()),
);
Ok(Value::Record(rec))
}
// --- logrotate (1007) ---
/// logrotate — Run logrotate with a configuration file inside the workspace.
/// Usage: logrotate_run(config) or logrotate_run(config, force). The configuration
/// must be an existing file inside the workspace, and its path may not begin with
/// '-'. Its paths must be relative and without '..', and it may not contain a
/// directive that runs a program or sends mail (prerotate, postrotate, firstaction,
/// lastaction, preremove, mail, mailfirst, maillast); each is refused by policy.
/// The state file is <config>.state beside it, so no system state is written.
/// force is a Bool. A nonzero exit is E_TOOL_FAILED.
fn bi_logrotate_run(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("logrotate_run", &input)?;
k8s_max_args("logrotate_run", &args, 2)?;
let config = k8s_need(
"logrotate_run",
"a configuration file path",
&args,
0,
tf_plain,
)?;
let force = match args.get(1) {
None => false,
Some(Value::Bool(b)) => *b,
Some(other) => {
return Err(crate::safety::bad_arg(
"logrotate_run",
"force: Bool",
other.type_name(),
))
}
};
let config_path = workspace_path("logrotate_run", &config, &workspace_root_abs())?;
if !config_path.is_file() {
return Err(crate::safety::not_found(
"logrotate_run",
"configuration file",
&config,
));
}
let size = std::fs::metadata(&config_path)
.map_err(|e| crate::safety::fs_error("logrotate_run", &config_path, &e))?
.len();
if size > LOGROTATE_CONFIG_MAX {
return Err(crate::safety::budget_exceeded(
"logrotate_run",
format!("the configuration is {size} bytes; the limit is {LOGROTATE_CONFIG_MAX}"),
"split the configuration or shorten it",
));
}
let text = read_text_capped("logrotate_run", &config_path)?;
logrotate_config_check("logrotate_run", &text)?;
let dir = config_path
.parent()
.map(std::path::Path::to_path_buf)
.unwrap_or_else(workspace_root_abs);
let mut state_os = config_path.clone().into_os_string();
state_os.push(".state");
let state_arg = std::path::PathBuf::from(state_os).display().to_string();
let config_arg = config_path.display().to_string();
let mut argv: Vec<&str> = vec!["-s", state_arg.as_str()];
if force {
argv.push("-f");
}
argv.push(config_arg.as_str());
let run = tool_run(
"logrotate_run",
"logrotate",
Some(dir.as_path()),
&argv,
Keep::Tail(TOOL_KEEP_TAIL),
TOOL_DEADLINE_SECS,
)?;
let run = finished("logrotate_run", run)?;
let mut rec = BTreeMap::new();
rec.insert("config".to_string(), Value::Str(config));
rec.insert("state".to_string(), Value::Str(state_arg));
rec.insert("forced".to_string(), Value::Bool(force));
rec.insert(
"stdout".to_string(),
Value::Str(run.stdout.trim().to_string()),
);
rec.insert("stderr".to_string(), Value::Str(run.stderr.clone()));
Ok(Value::Record(rec))
}
// --- ltrace (1008) ---
/// ltrace — Trace library calls of one program, counted with ltrace -c.
/// Usage: ltrace_cmd(command). The command is split on whitespace and run
/// directly, with no shell; the program may not begin with '-'. Returns
/// {command, exit_code, stdout, trace, stdout_truncated}. The exit code is the
/// traced program's and is data. A missing ltrace is E_TOOL_MISSING.
fn bi_ltrace_cmd(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("ltrace_cmd", &input)?;
k8s_max_args("ltrace_cmd", &args, 1)?;
let command = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => return Err(crate::safety::arg_err("ltrace requires a command to trace")),
};
let words = argv_words("ltrace_cmd", &command)?;
crate::safety::guard_exec("ltrace", &command)?;
let mut argv: Vec<&str> = vec!["-c", "--"];
argv.extend(words.iter().map(String::as_str));
let run = tool_run_stderr(
"ltrace_cmd",
"ltrace",
None,
&argv,
Keep::Tail(TOOL_MAX_TEXT),
TOOL_MAX_TEXT,
TOOL_DEADLINE_SECS,
)?;
let mut rec = BTreeMap::new();
rec.insert("command".to_string(), Value::Str(command));
rec.insert("exit_code".to_string(), Value::Int(exit_code_of(&run)));
rec.insert("stdout".to_string(), Value::Str(run.stdout.clone()));
rec.insert("trace".to_string(), Value::Str(run.stderr.clone()));
rec.insert(
"stdout_truncated".to_string(),
Value::Bool(run.stdout_truncated),
);
Ok(Value::Record(rec))
}
// --- valgrind (1009-1010) ---
/// valgrind — Run a program under memcheck.
/// Usage: valgrind_run(program)
/// The program is one argument, not option-like, and runs with no arguments of
/// its own. Its effect is Exec. A nonzero exit is E_TOOL_FAILED with the tail of
/// the valgrind report.
fn bi_valgrind_run(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
devtool_valgrind("valgrind_run", &["--tool=memcheck"], &args, input)
}
/// valgrind memcheck — Run a program under memcheck with full leak checking.
/// Usage: valgrind_memcheck(program)
/// The same argument rules as valgrind_run; adds --leak-check=full and
/// --show-leak-kinds=all. Its effect is Exec.
fn bi_valgrind_memcheck(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
devtool_valgrind(
"valgrind_memcheck",
&[
"--tool=memcheck",
"--leak-check=full",
"--show-leak-kinds=all",
],
&args,
input,
)
}
// --- gdb (1011-1012) ---
/// gdb run — Run a program under gdb in batch mode.
/// Usage: gdb_run(program) or gdb_run(program, commands)
/// `commands` is one gdb command per line (default "run", "bt", "quit"); each
/// becomes one -ex argument. gdb can run shell commands, so the call is
/// approval-gated as an exec. A nonzero exit is E_TOOL_FAILED.
fn bi_gdb_run(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("gdb_run", &input)?;
let program = k8s_need("gdb_run", "program path", &args, 0, tf_plain)?;
let commands = match k8s_arg("gdb_run", "gdb commands", &args, 1, devtool_text_arg)? {
Some(text) => devtool_gdb_commands("gdb_run", &text)?,
None => vec!["run".to_string(), "bt".to_string(), "quit".to_string()],
};
let mut argv: Vec<String> = vec!["--batch".to_string()];
for cmd in commands {
argv.push("-ex".to_string());
argv.push(cmd);
}
argv.push(program.clone());
let refs: Vec<&str> = argv.iter().map(String::as_str).collect();
let run = finished(
"gdb_run",
tool_run(
"gdb_run",
"gdb",
None,
&refs,
Keep::Tail(TOOL_KEEP_TAIL),
TOOL_LONG_DEADLINE_SECS,
)?,
)?;
let mut rec = run_fields(run, "output", "stderr");
rec.insert("program".to_string(), Value::Str(program));
Ok(Value::Record(rec))
}
/// gdb backtrace — Print the full backtrace from a core file.
/// Usage: gdb_bt(program, core_file)
/// Both paths are required, not option-like, and the core file must exist. The
/// backtrace keeps its last 4 MiB. A nonzero exit is E_TOOL_FAILED.
fn bi_gdb_bt(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("gdb_bt", &input)?;
let program = k8s_need("gdb_bt", "program path", &args, 0, tf_plain)?;
let corefile = k8s_need("gdb_bt", "core file path", &args, 1, tf_plain)?;
if !std::path::Path::new(&corefile).is_file() {
return Err(crate::safety::not_found("gdb_bt", "core file", &corefile));
}
let run = finished(
"gdb_bt",
tool_run(
"gdb_bt",
"gdb",
None,
&[
"--batch",
"-ex",
"bt full",
program.as_str(),
corefile.as_str(),
],
Keep::Tail(TOOL_MAX_TEXT),
TOOL_DEADLINE_SECS,
)?,
)?;
let mut rec = run_fields(run, "backtrace", "stderr");
rec.insert("program".to_string(), Value::Str(program));
rec.insert("core".to_string(), Value::Str(corefile));
Ok(Value::Record(rec))
}
// --- lldb (1013) ---
/// lldb — Run a program under LLDB in batch mode and return LLDB's report.
/// Usage: lldb_run(program). The program must be an existing file, and its path
/// may not begin with '-'. LLDB runs the program, prints a backtrace if it stops,
/// and quits. Stdin is closed, and the call has a 120 s deadline. A nonzero exit
/// is E_TOOL_FAILED with the tail of stderr; a missing lldb is E_TOOL_MISSING.
fn bi_lldb_run(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("lldb_run", &input)?;
k8s_max_args("lldb_run", &args, 1)?;
let program = k8s_need("lldb_run", "a program path", &args, 0, tf_plain)?;
if !std::path::Path::new(&program).is_file() {
return Err(crate::safety::not_found("lldb_run", "program", &program));
}
let argv = [
"--batch",
"-o",
"run",
"-o",
"bt",
"-o",
"quit",
program.as_str(),
];
let run = tool_run(
"lldb_run",
"lldb",
None,
&argv,
Keep::Tail(TOOL_KEEP_TAIL),
TOOL_DEADLINE_SECS,
)?;
let output = tool_text("lldb_run", run)?;
let mut rec = BTreeMap::new();
rec.insert("program".to_string(), Value::Str(program));
rec.insert("output".to_string(), Value::Str(output.trim().to_string()));
Ok(Value::Record(rec))
}
// --- objdump (1014-1015) ---
/// objdump disassemble — Disassemble a binary.
fn bi_objdump_disasm(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let path = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(crate::safety::arg_err(
"objdump_disasm requires a binary path",
))
}
};
// The path goes to objdump positionally; a leading `-` would be an option.
crate::safety::reject_option_like("objdump_disasm", std::slice::from_ref(&path))?;
let mut cmd = crate::safety::child_command("objdump");
cmd.args(["-d", &path]);
let output = bounded_cmd_output("objdump_disasm", "objdump", cmd, TOOL_DEADLINE_SECS);
match output {
// A failed run answered {success: false, ...} at exit 0: a missing or
// non-binary file read as an inspection result.
Ok(o) if !o.status.success() => Err(crate::safety::tool_failed(
"objdump_disasm",
"objdump",
o.status.code(),
&String::from_utf8_lossy(&o.stderr),
)),
Ok(o) => {
let mut rec = BTreeMap::new();
rec.insert("path".to_string(), Value::Str(path));
rec.insert(
"disassembly".to_string(),
Value::Str(String::from_utf8_lossy(&o.stdout).to_string()),
);
Ok(Value::Record(rec))
}
Err(e) => Err(crate::safety::tool_missing(
"objdump",
"objdump",
&e.to_string(),
)),
}
}
/// objdump headers — Show binary headers.
fn bi_objdump_headers(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let path = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(crate::safety::arg_err(
"objdump_headers requires a binary path",
))
}
};
// The path goes to objdump positionally; a leading `-` would be an option.
crate::safety::reject_option_like("objdump_headers", std::slice::from_ref(&path))?;
let mut cmd = crate::safety::child_command("objdump");
cmd.args(["-h", &path]);
let output = bounded_cmd_output("objdump_headers", "objdump", cmd, TOOL_DEADLINE_SECS);
match output {
// A failed run answered {success: false, ...} at exit 0: a missing or
// non-binary file read as an inspection result.
Ok(o) if !o.status.success() => Err(crate::safety::tool_failed(
"objdump_headers",
"objdump",
o.status.code(),
&String::from_utf8_lossy(&o.stderr),
)),
Ok(o) => {
let mut rec = BTreeMap::new();
rec.insert("path".to_string(), Value::Str(path));
rec.insert(
"headers".to_string(),
Value::Str(String::from_utf8_lossy(&o.stdout).trim().to_string()),
);
Ok(Value::Record(rec))
}
Err(e) => Err(crate::safety::tool_missing(
"objdump",
"objdump",
&e.to_string(),
)),
}
}
// --- nm (1016) ---
/// nm — list the symbols of an object file or executable.
/// Usage: nm_symbols(path). Returns an Array of {address, type, name}, with the
/// demangled name. An undefined symbol has no address, so its address is null.
/// The path must be an existing file and may not begin with '-'. Output past
/// 4 MiB is E_BUDGET_EXCEEDED; a missing nm is E_TOOL_MISSING.
fn bi_nm_symbols(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("nm_symbols", &input)?;
k8s_max_args("nm_symbols", &args, 1)?;
let path = k8s_need("nm_symbols", "an object file path", &args, 0, tf_plain)?;
if !std::path::Path::new(&path).is_file() {
return Err(crate::safety::not_found("nm_symbols", "object file", &path));
}
let run = tool_run(
"nm_symbols",
"nm",
None,
&["--demangle", path.as_str()],
Keep::Head(TOOL_MAX_TEXT),
TOOL_DEADLINE_SECS,
)?;
let text = tool_text("nm_symbols", run)?;
Ok(Value::Array(
text.lines()
.filter(|l| !l.trim().is_empty())
.map(nm_record)
.collect(),
))
}
// --- readelf (1017-1018) ---
/// readelf headers — Show ELF file headers.
fn bi_readelf_headers(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let path = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(crate::safety::arg_err(
"readelf_headers requires an ELF file path",
))
}
};
// The path goes to readelf positionally; a leading `-` would be an option.
crate::safety::reject_option_like("readelf_headers", std::slice::from_ref(&path))?;
let mut cmd = crate::safety::child_command("readelf");
cmd.args(["-h", &path]);
let output = bounded_cmd_output("readelf_headers", "readelf", cmd, TOOL_DEADLINE_SECS);
match output {
// A failed run answered {success: false, ...} at exit 0: a missing or
// non-binary file read as an inspection result.
Ok(o) if !o.status.success() => Err(crate::safety::tool_failed(
"readelf_headers",
"readelf",
o.status.code(),
&String::from_utf8_lossy(&o.stderr),
)),
Ok(o) => {
let mut rec = BTreeMap::new();
rec.insert("path".to_string(), Value::Str(path));
rec.insert(
"headers".to_string(),
Value::Str(String::from_utf8_lossy(&o.stdout).trim().to_string()),
);
Ok(Value::Record(rec))
}
Err(e) => Err(crate::safety::tool_missing(
"readelf",
"readelf",
&e.to_string(),
)),
}
}
/// readelf sections — Show ELF section headers.
fn bi_readelf_sections(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let path = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => {
return Err(crate::safety::arg_err(
"readelf_sections requires an ELF file path",
))
}
};
// The path goes to readelf positionally; a leading `-` would be an option.
crate::safety::reject_option_like("readelf_sections", std::slice::from_ref(&path))?;
let mut cmd = crate::safety::child_command("readelf");
cmd.args(["-S", &path]);
let output = bounded_cmd_output("readelf_sections", "readelf", cmd, TOOL_DEADLINE_SECS);
match output {
// A failed run answered {success: false, ...} at exit 0: a missing or
// non-binary file read as an inspection result.
Ok(o) if !o.status.success() => Err(crate::safety::tool_failed(
"readelf_sections",
"readelf",
o.status.code(),
&String::from_utf8_lossy(&o.stderr),
)),
Ok(o) => {
let mut rec = BTreeMap::new();
rec.insert("path".to_string(), Value::Str(path));
rec.insert(
"sections".to_string(),
Value::Str(String::from_utf8_lossy(&o.stdout).trim().to_string()),
);
Ok(Value::Record(rec))
}
Err(e) => Err(crate::safety::tool_missing(
"readelf",
"readelf",
&e.to_string(),
)),
}
}
// --- strings (1019) ---
/// strings — Extract printable strings from a binary file.
fn bi_strings_extract(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
// In-process on every OS, reading the file through the read checks. This
// ran the `strings` program (absent on Windows, and handed the path
// positionally) and fell back to this scan on any failure; the fallback's
// errors were labelled `bat`; and a negative minimum became a huge usize,
// so nothing matched.
let path = match args.first() {
Some(Value::Str(s)) => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"strings",
"path: String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
let min_len = match args.get(1) {
None => 4,
Some(Value::Int(n)) if (1..=4096).contains(n) => *n as usize,
Some(other) => {
return Err(crate::safety::bad_arg(
"strings",
"a minimum length from 1 to 4096",
&other.to_display_string(),
))
}
};
let validated = validate_read_path(&path)?;
let data = read_bytes_capped("strings", &validated)?;
let mut result = Vec::new();
let mut current = String::new();
for &byte in &data {
// Printable ASCII and tab, as GNU strings counts them.
if (0x20..0x7f).contains(&byte) || byte == 0x09 {
current.push(byte as char);
} else {
if current.len() >= min_len {
result.push(Value::Str(std::mem::take(&mut current)));
}
current.clear();
}
}
if current.len() >= min_len {
result.push(Value::Str(current));
}
Ok(Value::Array(result))
}
// --- chgrp (1020) ---
/// chgrp — Change the group of one file.
/// Usage: chgrp(group, path). The group is a name or numeric id and may not begin
/// with '-'; the path must exist and is passed after `--`. The result is
/// {group, path}. A refusal by chgrp (unknown group, not permitted) is
/// E_TOOL_FAILED. On Windows the group is an account name set as the file's
/// primary group through the security API (see chgrp_windows).
///
/// It used to answer `success: false` as a value when chgrp refused, ignore
/// surplus arguments and pipeline input, and run with no deadline.
fn bi_chgrp(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("chgrp", &input)?;
k8s_max_args("chgrp", &args, 2)?;
let group = k8s_need("chgrp", "a group name", &args, 0, tf_plain)?;
let path = match args.get(1) {
Some(Value::Str(s)) if !s.is_empty() && !s.contains('\0') => s.clone(),
other => {
return Err(crate::safety::bad_arg(
"chgrp",
"path: a non-empty String",
other.map(|v| v.type_name()).unwrap_or("nothing"),
))
}
};
#[cfg(windows)]
{
chgrp_windows(group, path)
}
#[cfg(not(windows))]
{
chgrp_unix(group, path)
}
}
#[cfg(not(windows))]
fn chgrp_unix(group: String, path: String) -> Result<Value> {
// Jailed as the Windows branch is; this one had no guard.
let target = guard_local_write("chgrp", &path)?;
if std::fs::symlink_metadata(&target).is_err() {
return Err(crate::safety::not_found("chgrp", "file", &path));
}
let run = tool_run(
"chgrp",
"chgrp",
None,
&["--", &group, &target],
Keep::Tail(TOOL_STDERR_TAIL),
TOOL_DEADLINE_SECS,
)?;
if !run.success {
return Err(crate::safety::tool_failed(
"chgrp",
&run.command,
run.code,
&run.stderr,
));
}
let mut rec = BTreeMap::new();
rec.insert("group".to_string(), Value::Str(group));
rec.insert("path".to_string(), Value::Str(path));
Ok(Value::Record(rec))
}
// --- mtr (1021) ---
/// mtr — Network diagnostic tool combining traceroute and ping.
/// Usage: mtr_trace(host) or mtr_trace(host, cycles). The host is a name or IP
/// literal and may not begin with '-'. cycles is 1..=100 and defaults to 5. The
/// result is {host, report}. A nonzero exit is E_TOOL_FAILED; a missing mtr is
/// E_TOOL_MISSING. The run has a 120 s deadline.
fn bi_mtr_trace(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
net_refuse_in_transaction("mtr_trace")?;
k8s_no_input("mtr_trace", &input)?;
k8s_max_args("mtr_trace", &args, 2)?;
let host = k8s_need("mtr_trace", "a hostname", &args, 0, net_target_host)?;
let cycles = match args.get(1) {
None => 5,
Some(Value::Int(n)) if (1..=100).contains(n) => *n,
Some(other) => {
return Err(crate::safety::bad_arg(
"mtr_trace",
"cycles: Int 1..=100",
other.type_name(),
))
}
};
let cycles_s = cycles.to_string();
let run = tool_run(
"mtr_trace",
"mtr",
None,
&[
"--report",
"--report-cycles",
cycles_s.as_str(),
host.as_str(),
],
Keep::Head(TOOL_MAX_TEXT),
TOOL_DEADLINE_SECS,
)?;
let report = tool_text("mtr_trace", run)?;
let mut rec = BTreeMap::new();
rec.insert("host".to_string(), Value::Str(host));
rec.insert("report".to_string(), Value::Str(report.trim().to_string()));
Ok(Value::Record(rec))
}
// --- host (1022) ---
/// host — DNS lookup utility.
/// Usage: host_lookup(hostname). The name is a host name or IP literal and may
/// not begin with '-'. Returns {hostname, result}, where result is the resolver
/// tool's text. A lookup the resolver reports as failed is E_TOOL_FAILED; a
/// missing resolver tool is E_TOOL_MISSING.
fn bi_host_lookup(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
net_refuse_in_transaction("host_lookup")?;
k8s_no_input("host_lookup", &input)?;
k8s_max_args("host_lookup", &args, 1)?;
let hostname = k8s_need("host_lookup", "a hostname", &args, 0, net_target_host)?;
let tool = if cfg!(windows) { "nslookup" } else { "host" };
let run = tool_run(
"host_lookup",
tool,
None,
&[hostname.as_str()],
Keep::Head(TOOL_MAX_TEXT),
TOOL_DEADLINE_SECS,
)?;
let result = tool_text("host_lookup", run)?;
let mut rec = BTreeMap::new();
rec.insert("hostname".to_string(), Value::Str(hostname));
rec.insert("result".to_string(), Value::Str(result.trim().to_string()));
Ok(Value::Record(rec))
}
// --- nc/netcat (1023-1024) ---
/// nc connect — Test whether a TCP port accepts a connection, using netcat.
/// Usage: nc_connect(host, port). The port is an Int 1..=65535 and the host is a
/// name or IP literal that may not begin with '-'. Returns {host, port, open,
/// output}. A refused or unanswered connection is open false: that is the answer.
/// Any other netcat failure is E_TOOL_FAILED, and a missing netcat is E_TOOL_MISSING.
fn bi_nc_connect(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
net_refuse_in_transaction("nc_connect")?;
k8s_no_input("nc_connect", &input)?;
k8s_max_args("nc_connect", &args, 2)?;
let host = k8s_need("nc_connect", "a host", &args, 0, net_target_host)?;
let port = match args.get(1) {
Some(Value::Int(n)) => net_port("nc_connect", *n)?,
Some(other) => {
return Err(crate::safety::bad_arg(
"nc_connect",
"port: Int 1..=65535",
other.type_name(),
))
}
None => return Err(crate::safety::arg_err("nc_connect requires a port")),
};
let port_s = port.to_string();
let run = tool_run_stderr(
"nc_connect",
"nc",
None,
&["-z", "-v", "-w", "5", host.as_str(), port_s.as_str()],
Keep::Tail(NET_ERR_MAX),
NET_ERR_MAX,
TOOL_DEADLINE_SECS,
)?;
let text = format!("{}{}", run.stdout, run.stderr).trim().to_string();
let open = if run.success {
true
} else if nc_refusal(&text) {
false
} else {
return Err(crate::safety::tool_failed(
"nc_connect",
&run.command,
run.code,
&run.stderr,
));
};
let mut rec = BTreeMap::new();
rec.insert("host".to_string(), Value::Str(host));
rec.insert("port".to_string(), Value::Int(port));
rec.insert("open".to_string(), Value::Bool(open));
rec.insert("output".to_string(), Value::Str(text));
Ok(Value::Record(rec))
}
/// nc_listen — accept one TCP connection on a port, read it to EOF or a byte
/// limit, then close both. Binds 127.0.0.1 unless `host` is given. The bind
/// address and the accepted peer pass the same network policy as net_listen,
/// and `timeout_ms` bounds the whole call (accept and read), so a stalled peer
/// is an E_IO error rather than a silent short read.
fn bi_nc_listen(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const NAME: &str = "nc_listen";
if input.is_some() || !(1..=2).contains(&args.len()) {
return Err(crate::safety::arg_err(
"nc_listen requires a port and optional options Record, without pipeline input",
));
}
let mut args = args.into_iter();
let port = match args.next() {
Some(Value::Int(n)) if (1..=65535).contains(&n) => n as u16,
_ => {
return Err(crate::safety::arg_err(
"nc_listen port must be an Int 1..65535",
))
}
};
let mut host: std::net::IpAddr = std::net::Ipv4Addr::LOCALHOST.into();
let mut timeout_ms: i64 = 1000;
let mut max_bytes: i64 = 65536;
if let Some(options) = args.next() {
let Value::Record(fields) = options else {
return Err(crate::safety::arg_err("nc_listen options must be a Record"));
};
for (key, value) in fields {
match (key.as_str(), value) {
("host", Value::Str(text)) => {
host = text.parse().map_err(|_| {
crate::safety::arg_err(
"nc_listen host must be a literal IPv4 or IPv6 address; resolve hostnames separately",
)
})?;
}
("timeout_ms", Value::Int(n)) if (1..=10000).contains(&n) => timeout_ms = n,
("max_bytes", Value::Int(n)) if (1..=1048576).contains(&n) => max_bytes = n,
(other, _) => {
return Err(crate::safety::arg_err(format!(
"nc_listen unknown option or out-of-range value: {other} (options: host String, timeout_ms 1..10000, max_bytes 1..1048576)"
)))
}
}
}
}
if crate::tx::is_active() {
return Err(crate::safety::unimplemented(
NAME,
"network effects and reads cannot be rolled back",
"finish the transaction first",
));
}
let addr = std::net::SocketAddr::new(host, port);
crate::safety::refuse_remote_bind(NAME, host)?;
guard_network(NAME, &format!("tcp://{addr}"))?;
let io_timeout = crate::safety::bounded_io_timeout(
NAME,
std::time::Duration::from_millis(timeout_ms as u64),
)?;
let deadline = std::time::Instant::now() + io_timeout;
let limit = max_bytes as usize;
let listener = std::net::TcpListener::bind(addr).map_err(|e| nc_io_error(NAME, e))?;
listener
.set_nonblocking(true)
.map_err(|e| nc_io_error(NAME, e))?;
let (mut stream, peer) = loop {
crate::safety::check_deadline()?;
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
return Err(nc_io_error(
NAME,
std::io::Error::new(
std::io::ErrorKind::TimedOut,
"no connection accepted before timeout",
),
));
}
match listener.accept() {
Ok(pair) => break pair,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(remaining.min(std::time::Duration::from_millis(5)));
}
Err(e) => return Err(nc_io_error(NAME, e)),
}
};
drop(listener);
// The bind address does not authorize an arbitrary incoming peer.
guard_network(NAME, &format!("tcp://{peer}"))?;
stream
.set_nonblocking(false)
.map_err(|e| nc_io_error(NAME, e))?;
let mut data: Vec<u8> = Vec::new();
let mut chunk = [0u8; 8192];
let mut truncated = false;
loop {
crate::safety::check_deadline()?;
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
return Err(nc_io_error(
NAME,
std::io::Error::new(
std::io::ErrorKind::TimedOut,
"read deadline exceeded before EOF",
),
));
}
stream
.set_read_timeout(Some(remaining.min(std::time::Duration::from_millis(50))))
.map_err(|e| nc_io_error(NAME, e))?;
match std::io::Read::read(&mut stream, &mut chunk) {
Ok(0) => break,
Ok(n) => {
// One byte past the limit proves more data existed, so truncated is exact.
let room = limit + 1 - data.len();
data.extend_from_slice(&chunk[..n.min(room)]);
if data.len() > limit {
data.truncate(limit);
truncated = true;
break;
}
}
Err(e)
if matches!(
e.kind(),
std::io::ErrorKind::Interrupted
| std::io::ErrorKind::TimedOut
| std::io::ErrorKind::WouldBlock
) => {}
Err(e) => return Err(nc_io_error(NAME, e)),
}
}
drop(stream);
let mut rec = BTreeMap::new();
rec.insert("peer".to_string(), Value::Str(format!("tcp://{peer}")));
rec.insert(
"data".to_string(),
Value::Str(String::from_utf8_lossy(&data).into_owned()),
);
rec.insert("bytes".to_string(), Value::Int(data.len() as i64));
rec.insert("truncated".to_string(), Value::Bool(truncated));
Ok(Value::Record(rec))
}
fn nc_io_error(name: &str, error: std::io::Error) -> anyhow::Error {
anyhow::Error::new(crate::safety::SafetyError {
code: crate::safety::ErrorCode::Io,
builtin: name.into(),
message: format!("{name}: {error}"),
hint: "A timeout before EOF is an error, not a short read; the partial data was discarded."
.into(),
approval: None,
did_you_mean: vec![],
expected: "one complete TCP connection".into(),
got: format!("{:?}", error.kind()),
})
}
// --- socat (1025) ---
/// socat — Multipurpose relay between two outbound connections.
/// Usage: socat_relay(address1, address2), with addresses such as "TCP:host:port"
/// or "UDP4:host:port". Only TCP, TCP4, UDP and UDP4 connections to a host and
/// port are accepted, with no options; EXEC, SYSTEM and SHELL run programs, OPEN
/// and CREATE write files, and LISTEN forms open listeners, so they are refused.
/// The relay has a 120 s deadline and is killed at it, so no relay outlives the
/// call. Returns {addr1, addr2, stdout, stdout_truncated}.
fn bi_socat_relay(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
net_refuse_in_transaction("socat_relay")?;
k8s_no_input("socat_relay", &input)?;
if args.len() != 2 {
return Err(crate::safety::arg_err(
"socat_relay takes exactly two addresses",
));
}
let (Some(Value::Str(a)), Some(Value::Str(b))) = (args.first(), args.get(1)) else {
return Err(crate::safety::arg_err(
"socat_relay addresses must be Strings",
));
};
let first = socat_address("socat_relay", a)?;
let second = socat_address("socat_relay", b)?;
let run = tool_run_stderr(
"socat_relay",
"socat",
None,
&[first.as_str(), second.as_str()],
Keep::Tail(TOOL_MAX_TEXT),
NET_ERR_MAX,
TOOL_DEADLINE_SECS,
)?;
let run = finished("socat_relay", run)?;
let mut rec = BTreeMap::new();
rec.insert("addr1".to_string(), Value::Str(first));
rec.insert("addr2".to_string(), Value::Str(second));
rec.insert(
"stdout".to_string(),
Value::Str(run.stdout.trim().to_string()),
);
rec.insert(
"stdout_truncated".to_string(),
Value::Bool(run.stdout_truncated),
);
Ok(Value::Record(rec))
}
// --- iperf3 (1026-1027) ---
/// Bounded runs of long-running tools (iperf3 server, entr, watchexec, nodemon).
#[path = "tool_runs.rs"]
mod tool_runs;
/// PE file version from the VS_FIXEDFILEINFO of a Windows image (platform_libcpp).
#[cfg(any(windows, test))]
#[path = "pe_version.rs"]
mod pe_version;
/// iperf3 server — run one iperf3 test as a server, bounded by timeout_ms.
fn bi_iperf3_server(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
tool_runs::iperf3_server(args, input)
}
/// iperf3_client — run one iperf3 bandwidth test against a server, as a client.
/// Usage: iperf3_client(host) or iperf3_client(host, port). The port is 1..=65535
/// and defaults to 5201. The host may not begin with '-'. The test runs with
/// JSON output, and its report is returned as a Record. A failed test is
/// E_TOOL_FAILED; a missing iperf3 is E_TOOL_MISSING; the run has a 120 s deadline.
fn bi_iperf3_client(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
net_refuse_in_transaction("iperf3_client")?;
k8s_no_input("iperf3_client", &input)?;
k8s_max_args("iperf3_client", &args, 2)?;
let host = k8s_need("iperf3_client", "a server host", &args, 0, net_target_host)?;
let port = match args.get(1) {
None => IPERF3_PORT,
Some(Value::Int(n)) => net_port("iperf3_client", *n)?,
Some(other) => {
return Err(crate::safety::bad_arg(
"iperf3_client",
"port: Int 1..=65535",
other.type_name(),
))
}
};
let port_s = port.to_string();
let run = tool_run_stderr(
"iperf3_client",
"iperf3",
None,
&["-c", host.as_str(), "-p", port_s.as_str(), "-J"],
Keep::Head(TOOL_MAX_JSON),
TOOL_STDERR_TAIL,
TOOL_DEADLINE_SECS,
)?;
tool_json("iperf3_client", run)
}
// --- bat (1028) ---
/// bat — Cat clone with syntax highlighting.
fn bi_bat_view(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
if input.is_some() || args.len() != 1 {
return Err(crate::safety::arg_err(
"bat requires one direct file path and no pipeline input",
));
}
let Some(Value::Str(path)) = args.into_iter().next() else {
return Err(crate::safety::arg_err("bat path must be String"));
};
let path = crate::safety::resolve_path_str(&path);
crate::safety::guard(crate::safety::GuardCtx {
builtin: "bat_view",
effect: crate::safety::Effect::ReadLocal,
what: "read plain file text",
targets: vec![path.clone()],
blast_radius: serde_json::json!({"files":1}),
reversible: true,
fs_paths: true,
})?;
Ok(Value::Str(line_read("bat", &path)?))
}
// --- eza (1029) ---
/// eza — Modern replacement for ls.
fn bi_eza_list(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "eza_list";
pkg_shape(B, &args, &input, 1)?;
let path = pkg_path_arg(B, &args, 0, ".")?;
let run = tool_run(
B,
"eza",
None,
&[
"-la",
"--git",
"--no-user",
"--time-style=long-iso",
path.as_str(),
],
Keep::Head(TOOL_MAX_TEXT),
TOOL_DEADLINE_SECS,
)?;
let text = tool_text(B, run)?;
search_list(
B,
text.lines()
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect(),
)
}
// --- zoxide (1030-1031) ---
/// zoxide add — Record a directory in the user's zoxide database.
/// Usage: zoxide_add() or zoxide_add(path)
/// The path defaults to ".", must exist as a directory, and is one word that is
/// not option-like. This writes the user's zoxide database, which lies outside
/// the workspace; it does not touch the directory itself.
fn bi_zoxide_add(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("zoxide_add", &input)?;
let path =
k8s_arg("zoxide_add", "directory", &args, 0, tf_plain)?.unwrap_or_else(|| ".".to_string());
if !std::path::Path::new(&path).is_dir() {
return Err(crate::safety::not_found("zoxide_add", "directory", &path));
}
let run = finished(
"zoxide_add",
tool_run(
"zoxide_add",
"zoxide",
None,
&["add", path.as_str()],
Keep::Tail(TOOL_KEEP_TAIL),
TOOL_DEADLINE_SECS,
)?,
)?;
let mut rec = run_fields(run, "stdout", "stderr");
rec.insert("path".to_string(), Value::Str(path));
Ok(Value::Record(rec))
}
/// zoxide query — The best directory in zoxide's database for a search term.
/// Usage: zoxide_query(term)
/// Answers a path string. No match is E_NOT_FOUND: zoxide exits nonzero with
/// nothing on stderr when it has no match, and anything else it says on
/// failure is E_TOOL_FAILED. The term is one word, not option-like.
fn bi_zoxide_query(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("zoxide_query", &input)?;
let term = k8s_need("zoxide_query", "search term", &args, 0, tf_plain)?;
let run = tool_run(
"zoxide_query",
"zoxide",
None,
&["query", term.as_str()],
Keep::Tail(TOOL_KEEP_TAIL),
TOOL_DEADLINE_SECS,
)?;
if !run.success && run.stderr.trim().is_empty() {
return Err(crate::safety::not_found(
"zoxide_query",
"directory matching",
&term,
));
}
let run = finished("zoxide_query", run)?;
let path = run.stdout.trim().to_string();
if path.is_empty() {
return Err(crate::safety::not_found(
"zoxide_query",
"directory matching",
&term,
));
}
Ok(Value::Str(path))
}
// --- delta (1032) ---
/// delta — Syntax-highlighting diff viewer.
fn bi_delta_diff(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "delta_diff";
pkg_shape(B, &args, &input, 2)?;
let file_a = k8s_need(B, "two file paths", &args, 0, pkg_plain)?;
let file_b = k8s_need(B, "two file paths", &args, 1, pkg_plain)?;
for file in [&file_a, &file_b] {
pkg_inside(B, file)?;
let meta = std::fs::metadata(file).map_err(|e| devtool_io(B, "file", file, &e))?;
if !meta.is_file() {
return Err(crate::safety::not_found(B, "file", file));
}
}
let run = tool_run(
B,
"delta",
None,
&[file_a.as_str(), file_b.as_str()],
Keep::Head(TOOL_MAX_TEXT),
TOOL_DEADLINE_SECS,
)?;
let diff = tool_text(B, run)?;
let mut rec = BTreeMap::new();
rec.insert("file_a".to_string(), Value::Str(file_a));
rec.insert("file_b".to_string(), Value::Str(file_b));
rec.insert("diff".to_string(), Value::Str(diff));
rec.insert("success".to_string(), Value::Bool(true));
Ok(Value::Record(rec))
}
// --- lazygit (1033) ---
/// `lazygit_open(path?)`: runs `lazygit -p <path>` with the terminal inherited
/// and waits for it to exit. Needs stdin and stdout to be terminals; otherwise
/// it refuses with E_NO_UI. The path is validated first, so a bad path is
/// refused the same way with or without a terminal.
fn bi_lazygit_open(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const BUILTIN: &str = "lazygit_open";
if input.is_some() {
return Err(crate::safety::bad_arg(
BUILTIN,
"no pipeline input",
"a piped value",
));
}
let path = match args.as_slice() {
[] => ".".to_string(),
[Value::Str(s)] => s.clone(),
[other] => {
return Err(crate::safety::bad_arg(
BUILTIN,
"lazygit_open(path?: String)",
&format!("{other:?}"),
))
}
more => {
return Err(crate::safety::bad_arg(
BUILTIN,
"lazygit_open(path?: String)",
&format!("{} arguments", more.len()),
))
}
};
crate::safety::reject_option_like(BUILTIN, std::slice::from_ref(&path))?;
let meta = match std::fs::metadata(&path) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(crate::safety::not_found(BUILTIN, "directory", &path))
}
Err(e) => return Err(crate::safety::fs_error(BUILTIN, &path, &e)),
};
if !meta.is_dir() {
return Err(crate::safety::bad_arg(
BUILTIN,
"a directory path",
&format!("{path}, which is not a directory"),
));
}
use std::io::IsTerminal;
if !(std::io::stdin().is_terminal() && std::io::stdout().is_terminal()) {
return Err(crate::safety::no_ui(BUILTIN));
}
// stdio is inherited, so lazygit draws on the user's terminal. Its stderr
// therefore cannot be captured for a tail; the failure carries only the
// exit status.
let mut cmd = crate::safety::child_command("lazygit");
crate::safety::strip_ironshell_env(&mut cmd);
let status = cmd
.arg("-p")
.arg(&path)
.status()
.map_err(|e| crate::safety::spawn_error(BUILTIN, "lazygit", &e))?;
if !status.success() {
return Err(crate::safety::tool_failed(
BUILTIN,
"lazygit",
status.code(),
"",
));
}
let mut rec = std::collections::BTreeMap::new();
rec.insert("path".to_string(), Value::Str(path));
rec.insert("exit_code".to_string(), Value::Int(0));
Ok(Value::Record(rec))
}
// --- fzf (1034) ---
/// fzf — Filters piped items by a case-insensitive substring of the query. No fzf process
/// is started, so this is not fuzzy matching and offers no interactive picker: it never
/// needs a terminal and never blocks. Items must be piped in (E_BAD_ARG otherwise).
fn bi_fzf_select(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "fzf_select";
k8s_max_args(B, &args, 1)?;
let query = match args.first() {
None => None,
Some(Value::Str(q)) => {
devtool_text_arg(B, "the query", q)?;
Some(q.to_lowercase())
}
Some(other) => {
return Err(crate::safety::arg_err(format!(
"{B}: the query must be a String, got {}",
other.type_name()
)))
}
};
let items: Vec<String> = match &input {
// Display colours and quotes its output; a string item is its own text.
Some(Value::Array(arr)) => arr
.iter()
.map(|v| match v {
Value::Str(s) => s.clone(),
other => other.to_json().to_string(),
})
.collect(),
Some(Value::Str(s)) => s.lines().map(str::to_string).collect(),
Some(other) => {
return Err(crate::safety::arg_err(format!(
"{B}: piped input must be an Array or a String, got {}",
other.type_name()
)))
}
None => {
return Err(crate::safety::arg_err(format!(
"{B} needs piped items, as in `items | {B}(query)`; it does not start an interactive picker"
)))
}
};
// A case-insensitive substring filter over the piped items. No process is
// started, so no terminal is needed and nothing can block.
let mut out = Vec::new();
for item in items {
let keep = match &query {
Some(q) => item.to_lowercase().contains(q.as_str()),
None => true,
};
if keep {
out.push(Value::Str(item));
}
}
Ok(Value::Array(out))
}
// --- rga (1035) ---
/// rga — ripgrep-all: search in PDFs, archives, etc.
fn bi_rga_search(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "rga_search";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 2)?;
let pattern = k8s_need(B, "a search pattern", &args, 0, search_text_check)?;
let path = pkg_path_arg(B, &args, 1, ".")?;
let regexp = format!("--regexp={pattern}");
let run = tool_run(
B,
"rga",
None,
&["--json", regexp.as_str(), path.as_str()],
Keep::Head(TOOL_MAX_JSON),
TOOL_DEADLINE_SECS,
)?;
let command = run.command.clone();
let text = stdout_with_exit1(B, run)?;
let mut out = Vec::new();
let mut matches = 0usize;
for line in text.lines().filter(|l| !l.is_empty()) {
let value = serde_json::from_str::<serde_json::Value>(line).map_err(|e| {
crate::safety::tool_failed(
B,
&command,
Some(0),
&format!("printed a line that is not JSON ({e})"),
)
})?;
if value.get("type").and_then(|t| t.as_str()) == Some("match") {
matches += 1;
if matches > SEARCH_MAX_RESULTS {
return Err(search_budget(B));
}
}
out.push(json_to_value(value));
}
Ok(Value::Array(out))
}
// --- sd (1036) ---
/// Native regex replacement independent of an installed sd executable.
fn bi_sd_replace(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
if (input.is_some() && args.len() != 2) || (input.is_none() && args.len() != 3) {
return Err(crate::safety::arg_err(
"sd requires pattern, replacement and direct or piped text, without conflicting input",
));
}
let mut args = args.into_iter();
let Some(Value::Str(pattern)) = args.next() else {
return Err(crate::safety::arg_err("sd requires pattern String"));
};
let Some(Value::Str(replacement)) = args.next() else {
return Err(crate::safety::arg_err("sd requires replacement String"));
};
let Some(Value::Str(text)) = input.or_else(|| args.next()) else {
return Err(crate::safety::arg_err("sd requires input String"));
};
let re = regex::Regex::new(&pattern)
.map_err(|e| crate::safety::arg_err(format!("sd invalid regex: {e}")))?;
Ok(Value::Str(regex_substitute(&re, &replacement, text, true)))
}
/// Reuse owned source text when a regex has no match.
fn regex_substitute(re: ®ex::Regex, replacement: &str, source: String, global: bool) -> String {
let replaced = if global {
re.replace_all(&source, replacement)
} else {
re.replace(&source, replacement)
};
match replaced {
std::borrow::Cow::Owned(result) => result,
std::borrow::Cow::Borrowed(_) => source,
}
}
// --- hyperfine (1037) ---
/// hyperfine — Command-line benchmarking tool.
fn bi_hyperfine_bench(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "hyperfine_bench";
k8s_no_input(B, &input)?;
let commands = pkg_each(B, "a command", &args, pkg_plain)?;
if commands.is_empty() {
return Err(crate::safety::arg_err(
"hyperfine_bench requires at least one command to benchmark",
));
}
// --shell=none runs each command without a shell: hyperfine splits it into
// words and runs the first, so no shell syntax is interpreted. The results
// come back through a file, since a Windows host has no /dev/stdout.
let export = scratch_file(B, "export.json", &[])?;
let export_path = export.display().to_string();
let mut argv: Vec<&str> = vec!["--shell=none", "--export-json", export_path.as_str()];
argv.extend(commands.iter().map(String::as_str));
let outcome = (|| -> Result<Value> {
let run = pkg_run(B, "hyperfine", &argv, PKG_RUN_DEADLINE_SECS)?;
pkg_output(B, run)?;
let text = devtool_read_text(B, "export file", &export_path)?;
serde_json::from_str::<serde_json::Value>(&text)
.map(json_to_value)
.map_err(|e| {
crate::safety::tool_failed(
B,
"hyperfine",
Some(0),
&format!("exported output that is not JSON ({e})"),
)
})
})();
scratch_remove(&export);
outcome
}
// --- tokei (1038) ---
/// tokei — Count lines of code by language.
fn bi_tokei_count(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "tokei_count";
pkg_shape(B, &args, &input, 1)?;
let path = pkg_path_arg(B, &args, 0, ".")?;
let run = tool_run(
B,
"tokei",
None,
&["--output", "json", path.as_str()],
Keep::Head(TOOL_MAX_JSON),
TOOL_DEADLINE_SECS,
)?;
tool_json(B, run)
}
// --- entr (1039) ---
/// entr — Run a command at start and again on each change to the watched paths,
/// bounded by timeout_ms.
fn bi_entr_watch(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
tool_runs::entr_watch(args, input)
}
// --- watchexec (1040) ---
/// watchexec — Run a command at start and on each change under the watched
/// paths (no shell), bounded by timeout_ms.
fn bi_watchexec_run(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
tool_runs::watchexec_run(args, input)
}
// --- just (1041-1042) ---
/// just — Run a recipe from the justfile in the process's directory.
/// Usage: just_run(recipe) or just_run(recipe, arg...)
/// The recipe and each argument are one word, not option-like, and at most 256
/// arguments are passed. A recipe runs project code, so its effect is Exec. A
/// nonzero exit is E_TOOL_FAILED.
fn bi_just_run(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("just_run", &input)?;
let recipe = k8s_need("just_run", "recipe name", &args, 0, devtool_word)?;
let mut argv: Vec<String> = vec![recipe.clone()];
for value in args.iter().skip(1) {
match value {
Value::Str(s) => argv.push(devtool_word("just_run", "a recipe argument", s)?),
other => {
return Err(crate::safety::arg_err(format!(
"just_run: recipe arguments must be Strings, got {}",
other.type_name()
)))
}
}
}
if argv.len() - 1 > DEVTOOL_MAX_ITEMS {
return Err(crate::safety::arg_err(format!(
"just_run: at most {DEVTOOL_MAX_ITEMS} recipe arguments, got {}",
argv.len() - 1
)));
}
let refs: Vec<&str> = argv.iter().map(String::as_str).collect();
let run = finished(
"just_run",
tool_run(
"just_run",
"just",
None,
&refs,
Keep::Tail(TOOL_KEEP_TAIL),
TOOL_LONG_DEADLINE_SECS,
)?,
)?;
let mut rec = run_fields(run, "stdout", "stderr");
rec.insert("recipe".to_string(), Value::Str(recipe));
Ok(Value::Record(rec))
}
/// just list — The recipes in the justfile in the process's directory.
/// Usage: just_list()
/// Answers an Array of {name, description?} Records. A failure, such as no
/// justfile, is E_TOOL_FAILED; it is never returned as a string.
fn bi_just_list(_args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("just_list", &input)?;
let run = tool_run(
"just_list",
"just",
None,
&["--list"],
Keep::Head(TOOL_MAX_TEXT),
TOOL_DEADLINE_SECS,
)?;
let text = tool_text("just_list", run)?;
let recipes: Vec<Value> = text
.lines()
.filter(|l| l.starts_with(char::is_whitespace) && !l.trim().is_empty())
.map(|line| {
let mut rec = BTreeMap::new();
let mut parts = line.trim().splitn(2, '#');
let name = parts.next().unwrap_or("").trim().to_string();
rec.insert("name".to_string(), Value::Str(name));
if let Some(desc) = parts.next() {
rec.insert(
"description".to_string(),
Value::Str(desc.trim().to_string()),
);
}
Value::Record(rec)
})
.collect();
Ok(Value::Array(recipes))
}
// --- task (1043-1044) ---
/// task — Run a Taskfile task.
//
// The Taskfile's commands run inside `task`, under its own interpreter, so this
// is an exec of the task definition and is gated as Exec. The name goes to
// `task` positionally, so it is refused when it could be read as an option. The
// run is bounded in output and time, and stdin is closed, so a prompt cannot
// hang it. A nonzero exit is a coded failure with the stderr tail, not a record.
fn bi_task_run(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("task_run", &input)?;
if args.len() != 1 {
return Err(crate::safety::bad_arg(
"task_run",
"one task name",
&format!("{} arguments", args.len()),
));
}
let task_name = match &args[0] {
Value::Str(s) if !s.is_empty() && !s.chars().any(|c| c.is_whitespace() || c == '\0') => {
s.clone()
}
other => {
return Err(crate::safety::bad_arg(
"task_run",
"a task name: a non-empty string with no whitespace",
other.type_name(),
))
}
};
refuse_option("task_run", &task_name)?;
let mut cmd = crate::safety::child_command("task");
cmd.arg(&task_name);
let (stdout, stderr) = run_checked("task_run", "task", cmd, LOCAL_DEADLINE_SECS)?;
let mut rec = BTreeMap::new();
rec.insert("task".to_string(), Value::Str(task_name));
rec.insert("stdout".to_string(), Value::Str(stdout.trim().to_string()));
rec.insert("stderr".to_string(), Value::Str(stderr.trim().to_string()));
Ok(Value::Record(rec))
}
/// task list — List available tasks.
fn bi_task_list(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
refuse_piped("task_list", &input)?;
if !args.is_empty() {
return Err(crate::safety::bad_arg(
"task_list",
"no arguments",
&format!("{} argument(s)", args.len()),
));
}
let mut cmd = crate::safety::child_command("task");
cmd.arg("--list");
let (stdout, _stderr) = run_checked("task_list", "task", cmd, LOCAL_DEADLINE_SECS)?;
Ok(Value::Str(stdout.trim().to_string()))
}
// --- direnv (1045-1046) ---
/// direnv allow — Authorise an .envrc file so direnv loads it.
/// Usage: direnv_allow() or direnv_allow(path)
/// The path defaults to ".", is one word that is not option-like, must exist,
/// and must lie inside the workspace. Allowing an .envrc lets its code run on
/// every cd into that directory. The effect table classes direnv_allow as
/// write_local, so nothing asks for approval; that is reported, not changed.
fn bi_direnv_allow(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("direnv_allow", &input)?;
let given =
k8s_arg("direnv_allow", "path", &args, 0, tf_plain)?.unwrap_or_else(|| ".".to_string());
let cwd = devtool_cwd("direnv_allow")?;
let path = workspace_path("direnv_allow", &given, &cwd)?;
if !path.exists() {
return Err(crate::safety::not_found(
"direnv_allow",
"path",
&path.display().to_string(),
));
}
let shown = path.display().to_string();
let run = finished(
"direnv_allow",
tool_run(
"direnv_allow",
"direnv",
None,
&["allow", shown.as_str()],
Keep::Tail(TOOL_KEEP_TAIL),
TOOL_DEADLINE_SECS,
)?,
)?;
let mut rec = run_fields(run, "stdout", "stderr");
rec.insert("path".to_string(), Value::Str(shown));
Ok(Value::Record(rec))
}
/// direnv status — direnv's report on the current directory and its .envrc.
/// Usage: direnv_status()
/// Answers {status, success} with the report text. A failure is E_TOOL_FAILED.
fn bi_direnv_status(_args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("direnv_status", &input)?;
let run = tool_run(
"direnv_status",
"direnv",
None,
&["status"],
Keep::Head(TOOL_MAX_TEXT),
TOOL_DEADLINE_SECS,
)?;
let text = tool_text("direnv_status", run)?;
let mut rec = BTreeMap::new();
rec.insert("status".to_string(), Value::Str(text.trim().to_string()));
rec.insert("success".to_string(), Value::Bool(true));
Ok(Value::Record(rec))
}
// --- asdf (1047-1048) ---
/// asdf list — Installed versions of a tool, or of every plugin.
/// Usage: asdf_list() or asdf_list(plugin)
/// Answers an Array of version Strings. A failure, such as an unknown plugin,
/// is E_TOOL_FAILED; it is never returned as a string.
fn bi_asdf_list(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("asdf_list", &input)?;
let plugin = k8s_arg("asdf_list", "plugin", &args, 0, tf_plain)?;
let mut argv: Vec<&str> = vec!["list"];
if let Some(p) = &plugin {
argv.push(p.as_str());
}
let run = tool_run(
"asdf_list",
"asdf",
None,
&argv,
Keep::Head(TOOL_MAX_TEXT),
TOOL_DEADLINE_SECS,
)?;
Ok(Value::Array(devtool_lines(&tool_text("asdf_list", run)?)))
}
/// asdf install — Install one version of a plugin's tool.
/// Usage: asdf_install(plugin) or asdf_install(plugin, version)
/// The version defaults to "latest". Both are one word, not option-like. The
/// install goes into asdf's own directory, outside the workspace, and fetches
/// and runs the plugin's code, so the effect is Exec and agent mode asks first.
fn bi_asdf_install(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("asdf_install", &input)?;
crate::safety::check_egress_unknown(
"asdf_install",
"the download comes from the plugin's own source, which is not named in the arguments",
)?;
let plugin = k8s_need("asdf_install", "plugin", &args, 0, tf_plain)?;
let version = k8s_arg("asdf_install", "version", &args, 1, tf_plain)?
.unwrap_or_else(|| "latest".to_string());
let run = finished(
"asdf_install",
tool_run(
"asdf_install",
"asdf",
None,
&["install", plugin.as_str(), version.as_str()],
Keep::Tail(TOOL_KEEP_TAIL),
TOOL_LONG_DEADLINE_SECS,
)?,
)?;
let mut rec = run_fields(run, "stdout", "stderr");
rec.insert("plugin".to_string(), Value::Str(plugin));
rec.insert("version".to_string(), Value::Str(version));
Ok(Value::Record(rec))
}
// --- mise (1049-1050) ---
/// mise use — Pin a tool version in the .mise.toml of the process's directory.
/// Usage: mise_use(tool@version)
/// The spec is one word such as node@20 and not option-like, so it cannot
/// select `--global`. The directory mise writes must lie inside the workspace.
/// mise installs the version and writes the config, yet the effect table
/// classes mise_use as write_local, so nothing asks for approval; reported,
/// not changed here.
fn bi_mise_use(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("mise_use", &input)?;
// `mise use` installs the tool version, which is fetched from a source the
// mise configuration or the tool's plugin names, not the argument.
crate::safety::check_egress_unknown(
"mise_use",
"mise installs the tool from a source set by its plugins and configuration, not the arguments",
)?;
let tool = k8s_need("mise_use", "tool@version", &args, 0, devtool_tool_spec)?;
let cwd = devtool_cwd("mise_use")?;
workspace_path("mise_use", ".", &cwd)?;
let run = finished(
"mise_use",
tool_run(
"mise_use",
"mise",
None,
&["use", tool.as_str()],
Keep::Tail(TOOL_KEEP_TAIL),
TOOL_LONG_DEADLINE_SECS,
)?,
)?;
let mut rec = run_fields(run, "stdout", "stderr");
rec.insert("tool".to_string(), Value::Str(tool));
Ok(Value::Record(rec))
}
/// mise list — Installed tool versions known to mise.
/// Usage: mise_list()
/// Answers an Array of Strings, one per line of mise's report. A failure is
/// E_TOOL_FAILED.
fn bi_mise_list(_args: Vec<Value>, input: Option<Value>) -> Result<Value> {
k8s_no_input("mise_list", &input)?;
let run = tool_run(
"mise_list",
"mise",
None,
&["list"],
Keep::Head(TOOL_MAX_TEXT),
TOOL_DEADLINE_SECS,
)?;
Ok(Value::Array(devtool_lines(&tool_text("mise_list", run)?)))
}
// ---------------------------------------------------------------------------
// Package and build tools: argument checks and the bounded runner they share
// ---------------------------------------------------------------------------
/// Deadline for a script, task, hook or formatter run. Installs, builds, tests
/// and compile-and-run calls use TOOL_LONG_DEADLINE_SECS.
const PKG_RUN_DEADLINE_SECS: u64 = 600;
/// The most arguments one call passes to its tool.
const PKG_MAX_ITEMS: usize = 256;
/// The `uv pip` subcommands offered. `compile` and `sync` rewrite lock or
/// requirement files and are not offered.
const UV_PIP_VERBS: &[&str] = &[
"install",
"uninstall",
"list",
"freeze",
"show",
"check",
"tree",
];
/// A value passed to a tool as one argument: not empty, no NUL, and never
/// option-like. A leading `-` would be read by the tool as one of its flags.
fn pkg_plain(builtin: &str, what: &str, value: &str) -> Result<String> {
crate::safety::reject_option_like(builtin, &[value.to_string()])?;
if value.trim().is_empty() || value.contains('\0') {
return Err(crate::safety::arg_err(format!(
"{builtin}: {what} must not be empty"
)));
}
Ok(value.to_string())
}
/// Whether a value names a path rather than a name: it has a separator or a
/// drive letter, or it starts with '.'.
fn pkg_is_pathlike(value: &str) -> bool {
value.contains('/')
|| value.contains('\\')
|| value.starts_with('.')
|| value.as_bytes().get(1) == Some(&b':')
}
/// `value` as a path, resolved the way the tool resolves it (against the
/// process directory it runs in), must lie inside the workspace.
fn pkg_inside(builtin: &str, value: &str) -> Result<()> {
let base = std::env::current_dir().unwrap_or_else(|_| workspace_root_abs());
workspace_path(builtin, value, &base).map(|_| ())
}
/// The path argument at `index`, or `default` when it is omitted. Both are
/// refused when option-like or outside the workspace.
fn pkg_path_arg(builtin: &str, args: &[Value], index: usize, default: &str) -> Result<String> {
let path =
k8s_arg(builtin, "path", args, index, pkg_plain)?.unwrap_or_else(|| default.to_string());
pkg_inside(builtin, &path)?;
Ok(path)
}
/// A script, task, hook or command name that a tool runs. When it names a path
/// it must lie inside the workspace.
fn pkg_name(builtin: &str, what: &str, value: &str) -> Result<String> {
let name = pkg_plain(builtin, what, value)?;
if pkg_is_pathlike(&name) {
pkg_inside(builtin, &name)?;
}
Ok(name)
}
/// A package spec an installer receives. `file:` and `link:` specs install from
/// a local directory, so they are refused. A spec that names a path must lie
/// inside the workspace.
fn pkg_spec(builtin: &str, what: &str, value: &str) -> Result<String> {
let spec = pkg_plain(builtin, what, value)?;
let lower = spec.to_ascii_lowercase();
if lower.contains("file:") || lower.contains("link:") {
return Err(crate::safety::arg_err(format!(
"{builtin}: {what} {spec:?} is a local path (file: or link:); install from a registry or a remote URL"
)));
}
if pkg_is_pathlike(&spec) {
pkg_inside(builtin, &spec)?;
}
Ok(spec)
}
/// A Go package pattern: plain, and inside the workspace.
fn pkg_go_target(builtin: &str, what: &str, value: &str) -> Result<String> {
let target = pkg_plain(builtin, what, value)?;
pkg_inside(builtin, &target)?;
Ok(target)
}
/// A Deno script: a path inside the workspace. Remote modules are refused,
/// because deno would fetch and run code that the workspace does not hold.
fn pkg_deno_script(builtin: &str, what: &str, value: &str) -> Result<String> {
let script = pkg_plain(builtin, what, value)?;
// A colon past the second character is a module specifier (https://, npm:,
// jsr:, data:), not a path. A drive letter sits at index 1 and is a path.
if script.find(':').is_some_and(|at| at > 1) {
return Err(crate::safety::arg_err(format!(
"{builtin}: {what} {script:?} is a module specifier, not a local path; fetch it into the workspace first"
)));
}
pkg_inside(builtin, &script)?;
Ok(script)
}
/// Every argument, each checked with `check`. A non-String or an option-like
/// argument is refused; none is dropped.
fn pkg_each(builtin: &str, what: &str, args: &[Value], check: K8sCheck) -> Result<Vec<String>> {
k8s_max_args(builtin, args, PKG_MAX_ITEMS)?;
let mut out = Vec::with_capacity(args.len());
for index in 0..args.len() {
if let Some(value) = k8s_arg(builtin, what, args, index, check)? {
out.push(value);
}
}
Ok(out)
}
/// Arguments that a script or program receives after its name. They pass
/// through as given and need only be Strings: after the name a leading '-'
/// belongs to the script, not to the tool. A non-String is refused.
fn pkg_trailing(builtin: &str, args: &[Value]) -> Result<Vec<String>> {
k8s_max_args(builtin, args, PKG_MAX_ITEMS)?;
args.iter()
.enumerate()
.map(|(index, value)| match value {
Value::Str(s) if !s.contains('\0') => Ok(s.clone()),
Value::Str(_) => Err(crate::safety::arg_err(format!(
"{builtin}: argument {} contains a NUL byte",
index + 2
))),
other => Err(crate::safety::arg_err(format!(
"{builtin}: argument {} must be a String, got {}",
index + 2,
other.type_name()
))),
})
.collect()
}
/// Splits the optional leading "--release", the one cargo option these
/// builtins take, from the arguments that follow it.
fn pkg_release(args: &[Value]) -> (bool, &[Value]) {
match args.first() {
Some(Value::Str(s)) if s == "--release" => (true, &args[1..]),
_ => (false, args),
}
}
/// Refuse pipeline input and more arguments than `max`.
fn pkg_shape(builtin: &str, args: &[Value], input: &Option<Value>, max: usize) -> Result<()> {
k8s_no_input(builtin, input)?;
k8s_max_args(builtin, args, max)
}
/// The last `max` bytes of `text`, cut on a character boundary.
fn pkg_tail(text: &str, max: usize) -> &str {
if text.len() <= max {
return text;
}
let mut start = text.len() - max;
while !text.is_char_boundary(start) {
start += 1;
}
&text[start..]
}
/// The output of a finished call, or its coded failure. A nonzero exit is
/// E_TOOL_FAILED, never a value.
fn pkg_output(builtin: &str, run: ToolRun) -> Result<(String, String)> {
if !run.success {
return Err(pkg_failure(builtin, run));
}
let stderr = run.stderr.clone();
let stdout = tool_text(builtin, run)?;
Ok((stdout.trim().to_string(), stderr))
}
/// The coded failure for a call that exited nonzero. A test runner prints its
/// failures on stdout and a compiler prints its errors on stderr, so the
/// message carries the end of both.
fn pkg_failure(builtin: &str, run: ToolRun) -> anyhow::Error {
let out_tail = pkg_tail(run.stdout.trim(), TOOL_STDERR_TAIL);
let detail = match (out_tail.is_empty(), run.stderr.is_empty()) {
(true, _) => run.stderr.clone(),
(false, true) => out_tail.to_string(),
(false, false) => format!("{out_tail}\n{}", run.stderr),
};
crate::safety::tool_failed(builtin, &run.command, run.code, &detail)
}
/// A successful call as the record these builtins return: the given fields,
/// then exit_code 0, success, stdout and stderr. A nonzero exit never gets here.
fn pkg_done(builtin: &str, run: ToolRun, fields: Vec<(&str, Value)>) -> Result<Value> {
let (stdout, stderr) = pkg_output(builtin, run)?;
let mut rec = BTreeMap::new();
for (key, value) in fields {
rec.insert(key.to_string(), value);
}
rec.insert("exit_code".to_string(), Value::Int(0));
rec.insert("success".to_string(), Value::Bool(true));
rec.insert("stdout".to_string(), Value::Str(stdout));
rec.insert("stderr".to_string(), Value::Str(stderr));
Ok(Value::Record(rec))
}
/// Runs `program` with `args` under `deadline_secs`, keeping the end of its output.
fn pkg_run(builtin: &str, program: &str, args: &[&str], deadline_secs: u64) -> Result<ToolRun> {
tool_run(
builtin,
program,
None,
args,
Keep::Tail(TOOL_KEEP_TAIL),
deadline_secs,
)
}
/// The Node package managers are `.cmd` shims on Windows, which a bare program
/// name does not find.
fn node_tool(name: &'static str) -> &'static str {
match name {
"npm" if cfg!(windows) => "npm.cmd",
"pnpm" if cfg!(windows) => "pnpm.cmd",
"yarn" if cfg!(windows) => "yarn.cmd",
"prettier" if cfg!(windows) => "prettier.cmd",
"eslint" if cfg!(windows) => "eslint.cmd",
_ => name,
}
}
/// `program verb <packages>` for an installer, with every package spec checked.
fn pkg_install(
builtin: &str,
program: &str,
verb: &[&str],
args: &[Value],
input: &Option<Value>,
) -> Result<Value> {
k8s_no_input(builtin, input)?;
crate::safety::check_egress_unknown(
builtin,
"the package registry is set by the project's package-manager configuration, not the arguments",
)?;
let packages = pkg_each(builtin, "package", args, pkg_spec)?;
let mut argv: Vec<&str> = verb.to_vec();
argv.extend(packages.iter().map(String::as_str));
let run = pkg_run(builtin, program, &argv, TOOL_LONG_DEADLINE_SECS)?;
pkg_done(builtin, run, Vec::new())
}
/// `program verb <script>` for a script runner: one checked name, nothing else.
fn pkg_named(
builtin: &str,
program: &str,
verb: &str,
args: &[Value],
input: &Option<Value>,
) -> Result<Value> {
pkg_shape(builtin, args, input, 1)?;
let script = k8s_need(builtin, "a script name", args, 0, pkg_name)?;
let run = pkg_run(
builtin,
program,
&[verb, script.as_str()],
PKG_RUN_DEADLINE_SECS,
)?;
pkg_done(builtin, run, vec![("script", Value::Str(script))])
}
// --- uv (1051-1053) ---
// --- uv (1051-1053) ---
/// uv run — Run a Python script with uv.
fn bi_uv_run(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "uv_run";
k8s_no_input(B, &input)?;
// `uv run` syncs the project's dependencies first, from the index uv's own
// configuration names (a uv.toml or UV_INDEX_URL can redirect it).
crate::safety::check_egress_unknown(
B,
"uv run installs the project's dependencies from an index set by uv's configuration, not the arguments",
)?;
let script = k8s_need(B, "a script or command", &args, 0, pkg_name)?;
let mut argv = vec!["run".to_string(), script];
argv.extend(pkg_trailing(B, args.get(1..).unwrap_or(&[]))?);
let refs: Vec<&str> = argv.iter().map(String::as_str).collect();
let run = pkg_run(B, "uv", &refs, PKG_RUN_DEADLINE_SECS)?;
pkg_done(B, run, Vec::new())
}
/// uv pip — Install packages with uv pip.
fn bi_uv_pip(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "uv_pip";
k8s_no_input(B, &input)?;
let verb =
k8s_arg(B, "subcommand", &args, 0, pkg_plain)?.unwrap_or_else(|| "install".to_string());
if !UV_PIP_VERBS.contains(&verb.as_str()) {
return Err(crate::safety::arg_err(format!(
"{B}: subcommand must be one of {}, got {verb:?}",
UV_PIP_VERBS.join(", ")
)));
}
crate::safety::check_egress_unknown(
B,
"the index is set by uv's configuration, not the arguments, and a source build runs its backend",
)?;
let packages = pkg_each(B, "package", args.get(1..).unwrap_or(&[]), pkg_spec)?;
let deadline = if matches!(verb.as_str(), "install" | "uninstall") {
TOOL_LONG_DEADLINE_SECS
} else {
TOOL_DEADLINE_SECS
};
let mut argv: Vec<&str> = vec!["pip", verb.as_str()];
argv.extend(packages.iter().map(String::as_str));
let run = pkg_run(B, "uv", &argv, deadline)?;
pkg_done(B, run, vec![("subcmd", Value::Str(verb))])
}
/// uv venv — Create a virtual environment with uv.
fn bi_uv_venv(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "uv_venv";
pkg_shape(B, &args, &input, 1)?;
let path = pkg_path_arg(B, &args, 0, ".venv")?;
let run = pkg_run(B, "uv", &["venv", path.as_str()], TOOL_DEADLINE_SECS)?;
pkg_done(B, run, vec![("path", Value::Str(path))])
}
// --- pipx (1054-1055) ---
/// pipx_install(package): pipx install of one package spec. A `file:` or
/// `link:` spec is refused, and so is a path outside the workspace.
fn bi_pipx_install(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "pipx_install";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 1)?;
crate::safety::check_egress_unknown(
B,
"the package index is set by pipx's configuration, not the argument",
)?;
let package = k8s_need(B, "a package spec", &args, 0, pkg_spec)?;
let run = pkg_run(
B,
"pipx",
&["install", package.as_str()],
TOOL_LONG_DEADLINE_SECS,
)?;
pkg_done(B, run, vec![("package", Value::Str(package))])
}
/// pipx list — List pipx-installed packages.
fn bi_pipx_list(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut cmd = crate::safety::child_command("pipx");
cmd.arg("list");
let output = bounded_cmd_output("pipx_list", "pipx", cmd, PROBE_DEADLINE_SECS);
match output {
Ok(o) if o.status.success() => Ok(Value::Str(
String::from_utf8_lossy(&o.stdout).trim().to_string(),
)),
// pipx's error text was returned as the list.
Ok(o) => Err(crate::safety::tool_failed(
"pipx",
"pipx list",
o.status.code(),
&String::from_utf8_lossy(&o.stderr),
)),
Err(e) => Err(crate::safety::tool_missing("pipx", "pipx", &e.to_string())),
}
}
// --- poetry (1056-1057) ---
/// poetry run — Run a command in a Poetry environment.
fn bi_poetry_run(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "poetry_run";
k8s_no_input(B, &input)?;
let command = k8s_need(B, "a command", &args, 0, pkg_name)?;
let mut argv = vec!["run".to_string(), command];
argv.extend(pkg_trailing(B, args.get(1..).unwrap_or(&[]))?);
let refs: Vec<&str> = argv.iter().map(String::as_str).collect();
let run = pkg_run(B, "poetry", &refs, PKG_RUN_DEADLINE_SECS)?;
pkg_done(B, run, Vec::new())
}
/// poetry install — Install Poetry project dependencies.
fn bi_poetry_install(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "poetry_install";
pkg_shape(B, &args, &input, 0)?;
crate::safety::check_egress_unknown(
B,
"the package sources are named by the project's pyproject.toml, not the arguments",
)?;
let run = pkg_run(B, "poetry", &["install"], TOOL_LONG_DEADLINE_SECS)?;
pkg_done(B, run, Vec::new())
}
// --- node (1058) ---
/// node — Run a Node.js script.
fn bi_node_run(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "node_run";
pkg_shape(B, &args, &input, 1)?;
let script = match args.first() {
Some(Value::Str(s)) if !s.trim().is_empty() && !s.contains('\0') => s.clone(),
_ => {
return Err(crate::safety::arg_err(
"node_run requires a script path or expression",
))
}
};
let run = if [".js", ".mjs", ".ts"]
.iter()
.any(|ext| script.ends_with(ext))
{
// A script file: a plain path inside the workspace, never a flag.
let path = pkg_plain(B, "script path", &script)?;
pkg_inside(B, &path)?;
pkg_run(B, "node", &[path.as_str()], PKG_RUN_DEADLINE_SECS)?
} else {
// An expression: the text after -e is the program, whatever it begins with.
pkg_run(B, "node", &["-e", script.as_str()], PKG_RUN_DEADLINE_SECS)?
};
pkg_done(B, run, Vec::new())
}
// --- npm (1059-1060) ---
/// npm run — Run an npm script.
fn bi_npm_run(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
pkg_named("npm_run", node_tool("npm"), "run", &args, &input)
}
/// npm install — Install npm packages.
fn bi_npm_install(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
pkg_install("npm_install", node_tool("npm"), &["install"], &args, &input)
}
// --- pnpm (1061-1062) ---
/// pnpm run — Run a pnpm script.
fn bi_pnpm_run(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
pkg_named("pnpm_run", node_tool("pnpm"), "run", &args, &input)
}
/// pnpm install — Install pnpm packages.
fn bi_pnpm_install(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
pkg_install(
"pnpm_install",
node_tool("pnpm"),
&["install"],
&args,
&input,
)
}
// --- yarn (1063-1064) ---
/// yarn run — Run a yarn script.
fn bi_yarn_run(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
pkg_named("yarn_run", node_tool("yarn"), "run", &args, &input)
}
/// yarn install — Install yarn packages.
fn bi_yarn_install(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let verb: &[&str] = if args.is_empty() {
&["install"]
} else {
&["add"]
};
pkg_install("yarn_install", node_tool("yarn"), verb, &args, &input)
}
// --- cargo (1065-1067) ---
/// Why a cargo build, test or run is refused under an allowlist: it fetches
/// crates, and the registry is set by `.cargo/config.toml` (source replacement
/// and mirrors), not by the arguments. Also `CARGO_REGISTRIES_*` in the environment.
const CARGO_EGRESS_REASON: &str =
"cargo fetches crates from a registry set by cargo's configuration, not the arguments";
/// cargo run — Run a Rust project.
fn bi_cargo_run(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "cargo_run";
k8s_no_input(B, &input)?;
crate::safety::check_egress_unknown(B, CARGO_EGRESS_REASON)?;
let (release, rest) = pkg_release(&args);
let program_args = pkg_each(B, "argument", rest, pkg_plain)?;
let mut argv: Vec<&str> = vec!["run"];
if release {
argv.push("--release");
}
if !program_args.is_empty() {
argv.push("--");
argv.extend(program_args.iter().map(String::as_str));
}
let run = pkg_run(B, "cargo", &argv, TOOL_LONG_DEADLINE_SECS)?;
pkg_done(B, run, Vec::new())
}
/// cargo build — Build a Rust project.
fn bi_cargo_build_cmd(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "cargo_build_cmd";
pkg_shape(B, &args, &input, 1)?;
crate::safety::check_egress_unknown(B, CARGO_EGRESS_REASON)?;
let release = match args.first() {
None => false,
Some(Value::Str(s)) if s == "--release" => true,
Some(Value::Str(s)) => {
return Err(crate::safety::arg_err(format!(
"{B}: its only argument is \"--release\", got {s:?}"
)))
}
Some(other) => {
return Err(crate::safety::arg_err(format!(
"{B}: its only argument is \"--release\", got a {}",
other.type_name()
)))
}
};
let mut argv: Vec<&str> = vec!["build"];
if release {
argv.push("--release");
}
let run = pkg_run(B, "cargo", &argv, TOOL_LONG_DEADLINE_SECS)?;
pkg_done(B, run, Vec::new())
}
/// cargo test — Run Rust tests.
fn bi_cargo_test_cmd(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "cargo_test_cmd";
k8s_no_input(B, &input)?;
crate::safety::check_egress_unknown(B, CARGO_EGRESS_REASON)?;
let (release, rest) = pkg_release(&args);
k8s_max_args(B, rest, 1)?;
let filter = pkg_each(B, "test name", rest, pkg_plain)?;
let mut argv: Vec<&str> = vec!["test"];
if release {
argv.push("--release");
}
argv.extend(filter.iter().map(String::as_str));
let run = pkg_run(B, "cargo", &argv, TOOL_LONG_DEADLINE_SECS)?;
pkg_done(B, run, Vec::new())
}
// --- rustup (1068-1069) ---
/// rustup show — Show installed Rust toolchains.
fn bi_rustup_show(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut cmd = crate::safety::child_command("rustup");
cmd.arg("show");
let output = bounded_cmd_output("rustup_show", "rustup", cmd, PROBE_DEADLINE_SECS);
match output {
Ok(o) if o.status.success() => Ok(Value::Str(
String::from_utf8_lossy(&o.stdout).trim().to_string(),
)),
// rustup's error text was returned as if it were the toolchain list.
Ok(o) => Err(crate::safety::tool_failed(
"rustup_show",
"rustup",
o.status.code(),
&String::from_utf8_lossy(&o.stderr),
)),
Err(e) => Err(crate::safety::tool_missing(
"rustup",
"rustup",
&e.to_string(),
)),
}
}
/// rustup_update(): rustup update, which fetches and installs toolchains. It
/// takes no arguments and no pipeline input.
fn bi_rustup_update(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "rustup_update";
pkg_shape(B, &args, &input, 0)?;
crate::safety::check_egress_unknown(
B,
"the toolchain servers are set by rustup's configuration, not the arguments",
)?;
let run = pkg_run(B, "rustup", &["update"], TOOL_LONG_DEADLINE_SECS)?;
pkg_done(B, run, Vec::new())
}
// --- go (1070-1072) ---
/// Why a go build, test or run is refused under an allowlist: it downloads
/// modules through GOPROXY (and GOFLAGS, go.env), which the arguments do not name.
const GO_EGRESS_REASON: &str =
"go fetches modules through GOPROXY, which is set by the environment or go env, not the arguments";
/// go run — Run a Go program.
fn bi_go_run(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "go_run";
pkg_shape(B, &args, &input, 1)?;
crate::safety::check_egress_unknown(B, GO_EGRESS_REASON)?;
let target = pkg_path_arg(B, &args, 0, ".")?;
let run = pkg_run(B, "go", &["run", target.as_str()], TOOL_LONG_DEADLINE_SECS)?;
pkg_done(B, run, Vec::new())
}
/// go build — Build a Go project.
fn bi_go_build(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "go_build";
k8s_no_input(B, &input)?;
crate::safety::check_egress_unknown(B, GO_EGRESS_REASON)?;
let packages = pkg_each(B, "package", &args, pkg_go_target)?;
let mut argv: Vec<&str> = vec!["build"];
argv.extend(packages.iter().map(String::as_str));
let run = pkg_run(B, "go", &argv, TOOL_LONG_DEADLINE_SECS)?;
pkg_done(B, run, Vec::new())
}
/// go test — Run Go tests.
fn bi_go_test(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "go_test";
pkg_shape(B, &args, &input, 1)?;
crate::safety::check_egress_unknown(B, GO_EGRESS_REASON)?;
let target = pkg_path_arg(B, &args, 0, "./...")?;
let run = pkg_run(
B,
"go",
&["test", "-v", target.as_str()],
TOOL_LONG_DEADLINE_SECS,
)?;
pkg_done(B, run, Vec::new())
}
// --- mage (1073) ---
/// mage — Run a mage target (Go task runner).
fn bi_mage_run(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "mage_run";
pkg_shape(B, &args, &input, 1)?;
let target = k8s_need(B, "a target name", &args, 0, pkg_plain)?;
let run = pkg_run(B, "mage", &[target.as_str()], PKG_RUN_DEADLINE_SECS)?;
pkg_done(B, run, vec![("target", Value::Str(target))])
}
// --- bun (1074-1075) ---
/// bun run — Run a script or file with Bun.
fn bi_bun_run(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
pkg_named("bun_run", "bun", "run", &args, &input)
}
/// bun install — Install packages with Bun.
fn bi_bun_install(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
let verb: &[&str] = if args.is_empty() {
&["install"]
} else {
&["add"]
};
pkg_install("bun_install", "bun", verb, &args, &input)
}
// --- deno (1076-1077) ---
/// deno run — Run a script with Deno.
fn bi_deno_run(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "deno_run";
pkg_shape(B, &args, &input, 1)?;
// Remote imports are fetched without a permission prompt, from URLs the
// script names, and an import map or DENO_* setting can redirect them.
crate::safety::check_egress_unknown(
B,
"deno fetches remote imports named by the script and its import map, not the arguments",
)?;
let script = k8s_need(B, "a script path", &args, 0, pkg_deno_script)?;
// --no-prompt: a permission the script lacks is an error, never a prompt
// on a closed stdin. No permission is granted.
let run = pkg_run(
B,
"deno",
&["run", "--no-prompt", script.as_str()],
PKG_RUN_DEADLINE_SECS,
)?;
pkg_done(B, run, Vec::new())
}
/// deno task — Run a deno task.
fn bi_deno_task(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "deno_task";
pkg_shape(B, &args, &input, 1)?;
let task = k8s_need(B, "a task name", &args, 0, pkg_plain)?;
let run = pkg_run(B, "deno", &["task", task.as_str()], PKG_RUN_DEADLINE_SECS)?;
pkg_done(B, run, vec![("task", Value::Str(task))])
}
// --- gh (1078-1080) ---
/// gh pr(subcommand, ...): gh pr with one of the listed subcommands. Reads
/// return the output; writes (create, comment, edit, close, reopen, ready)
/// change the remote and are approval-gated in agent mode.
fn bi_gh_pr(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
ops_forge("gh_pr", "gh", "pr", GH_PR_VERBS, &args, &input)
}
/// gh issue(subcommand, ...): gh issue with one of the listed subcommands. Writes
/// change the remote and are approval-gated in agent mode.
fn bi_gh_issue(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
ops_forge("gh_issue", "gh", "issue", GH_ISSUE_VERBS, &args, &input)
}
/// gh repo(subcommand, ...): gh repo list or view only. Every subcommand that
/// changes a repository is refused.
fn bi_gh_repo_cmd(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
// Both verbs (list, view) are GitHub API calls to the host gh is configured
// for (GH_HOST, the hosts file), which the arguments do not name, so there is
// no local mode to allow.
crate::safety::check_egress_unknown(
"gh_repo_cmd",
"gh contacts the host it is configured for (GH_HOST, the hosts file), not the arguments",
)?;
ops_forge("gh_repo_cmd", "gh", "repo", GH_REPO_VERBS, &args, &input)
}
// --- glab (1081-1082) ---
/// glab mr(subcommand, ...): glab mr with one of the listed subcommands. Writes
/// change the remote and are approval-gated in agent mode.
fn bi_glab_mr(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
ops_forge("glab_mr", "glab", "mr", GLAB_MR_VERBS, &args, &input)
}
/// glab issue(subcommand, ...): glab issue with one of the listed subcommands.
/// Writes change the remote and are approval-gated in agent mode.
fn bi_glab_issue(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
ops_forge(
"glab_issue",
"glab",
"issue",
GLAB_ISSUE_VERBS,
&args,
&input,
)
}
// --- act (1083) ---
/// act_run([event]): act, which runs GitHub Actions workflows for an event in
/// Docker containers. The event is a plain name; a leading `-` is refused. A
/// nonzero exit is E_TOOL_FAILED. Runs with a 15 minute deadline.
fn bi_act_run(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "act_run";
pkg_shape(B, &args, &input, 1)?;
let event = k8s_arg(B, "event", &args, 0, ops_event)?;
let mut argv: Vec<&str> = Vec::new();
let mut fields: Vec<(&str, Value)> = Vec::new();
if let Some(name) = event.as_deref() {
argv.push(name);
fields.push(("event", Value::Str(name.to_string())));
}
let run = pkg_run(B, "act", &argv, TOOL_LONG_DEADLINE_SECS)?;
pkg_done(B, run, fields)
}
// --- pre-commit (1084-1085) ---
/// pre-commit run — Run pre-commit hooks.
fn bi_pre_commit_run(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "pre_commit_run";
pkg_shape(B, &args, &input, 1)?;
// The first run clones each hook repository named in .pre-commit-config.yaml.
crate::safety::check_egress_unknown(
B,
"pre-commit clones the hook repositories named by the project's config, not the arguments",
)?;
let all = matches!(args.first(), Some(Value::Str(s)) if s == "--all-files");
let hook = if all {
None
} else {
k8s_arg(B, "hook name", &args, 0, pkg_plain)?
};
let mut argv: Vec<&str> = vec!["run"];
if all {
argv.push("--all-files");
}
if let Some(hook) = hook.as_deref() {
argv.push(hook);
}
let run = pkg_run(B, "pre-commit", &argv, TOOL_LONG_DEADLINE_SECS)?;
pkg_done(B, run, Vec::new())
}
/// pre-commit install — Install pre-commit hooks.
fn bi_pre_commit_install(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "pre_commit_install";
pkg_shape(B, &args, &input, 0)?;
// The hook is written into the repository's .git directory.
pkg_inside(B, ".")?;
let run = pkg_run(B, "pre-commit", &["install"], TOOL_DEADLINE_SECS)?;
pkg_done(B, run, Vec::new())
}
// --- buildah (1086-1087) ---
/// buildah_build(tag, [context]): buildah bud of a context directory inside the
/// workspace, tagged `tag`. A URL or git context is refused. Runs with a 15
/// minute deadline.
fn bi_buildah_build(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "buildah_build";
pkg_shape(B, &args, &input, 2)?;
// A build pulls the base images its Dockerfile's FROM lines name, so the
// registries are in the build context, not in the arguments.
crate::safety::check_egress_unknown(
B,
"the base images are named in the build context's Dockerfile, not in the arguments",
)?;
let tag = k8s_need(B, "an image tag", &args, 0, ops_image_ref)?;
let context = k8s_arg(B, "context", &args, 1, ops_scan_dir)?.unwrap_or_else(|| ".".to_string());
let run = tool_run(
B,
"buildah",
None,
&["bud", "-t", tag.as_str(), context.as_str()],
Keep::Tail(TOOL_KEEP_TAIL),
TOOL_LONG_DEADLINE_SECS,
)?;
pkg_done(B, run, vec![("tag", Value::Str(tag))])
}
/// buildah images — List Buildah images.
fn bi_buildah_images(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let mut cmd = crate::safety::child_command("buildah");
cmd.args(["images", "--json"]);
let output = bounded_cmd_output("buildah_images", "buildah", cmd, TOOL_DEADLINE_SECS);
match output {
Ok(o) if o.status.success() => {
let stdout = String::from_utf8_lossy(&o.stdout);
// Non-JSON output came back as a String, and a failure's stderr
// as the answer.
serde_json::from_str::<serde_json::Value>(&stdout)
.map(json_to_value)
.map_err(|e| {
crate::safety::tool_failed(
"buildah_images",
"buildah images --json",
o.status.code(),
&format!("output that is not JSON: {e}"),
)
})
}
Ok(o) => Err(crate::safety::tool_failed(
"buildah_images",
"buildah images",
o.status.code(),
&String::from_utf8_lossy(&o.stderr),
)),
Err(e) => Err(crate::safety::tool_missing(
"buildah",
"buildah",
&e.to_string(),
)),
}
}
// --- skopeo (1088-1089) ---
/// The skopeo egress rule. Only a `docker://` reference reaches a registry, and
/// `registries.conf` can redirect a registry to a mirror, which the arguments do
/// not show. So under an allowlist a `docker://` reference is refused as
/// destination-unknown, and the local transports (`dir:`, `oci:`, `docker-archive:`,
/// `oci-archive:`, `docker-daemon:`, `containers-storage:`) pass.
fn skopeo_registry_egress(builtin: &str, refs: &[&String]) -> Result<()> {
if refs.iter().any(|r| r.starts_with("docker://")) {
crate::safety::check_egress_unknown(
builtin,
"skopeo reaches a docker:// registry that registries.conf can redirect to a mirror, not the arguments",
)?;
}
Ok(())
}
/// skopeo_copy(src, dst): skopeo copy between two transport references. A
/// registry reference is a network copy; a local transport must name a path
/// inside the workspace. Runs with a 15 minute deadline.
fn bi_skopeo_copy(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "skopeo_copy";
pkg_shape(B, &args, &input, 2)?;
let src = k8s_need(B, "a source transport reference", &args, 0, ops_transport)?;
let dst = k8s_need(
B,
"a destination transport reference",
&args,
1,
ops_transport,
)?;
skopeo_registry_egress(B, &[&src, &dst])?;
let run = tool_run(
B,
"skopeo",
None,
&["copy", src.as_str(), dst.as_str()],
Keep::Tail(TOOL_KEEP_TAIL),
TOOL_LONG_DEADLINE_SECS,
)?;
pkg_done(
B,
run,
vec![("src", Value::Str(src)), ("dst", Value::Str(dst))],
)
}
/// skopeo_inspect(image): skopeo inspect of a transport reference, as JSON.
fn bi_skopeo_inspect(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "skopeo_inspect";
pkg_shape(B, &args, &input, 1)?;
let image = k8s_need(B, "an image transport reference", &args, 0, ops_transport)?;
skopeo_registry_egress(B, &[&image])?;
let run = tool_run(
B,
"skopeo",
None,
&["inspect", image.as_str()],
Keep::Head(TOOL_MAX_JSON),
TOOL_DEADLINE_SECS,
)?;
tool_json(B, run)
}
// --- trivy (1090-1091) ---
/// trivy_scan([path]): trivy fs of a path inside the workspace (default the
/// workspace directory), as JSON. Trivy may download its vulnerability database.
fn bi_trivy_scan(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "trivy_scan";
pkg_shape(B, &args, &input, 1)?;
// Trivy downloads its vulnerability database from a repository that is set by
// its configuration (TRIVY_DB_REPOSITORY, trivy.yaml), not the arguments, and
// it refreshes that database on a schedule. There is no local-only mode.
crate::safety::check_egress_unknown(
B,
"trivy downloads its vulnerability database from a repository set by its configuration, not the arguments",
)?;
// Trivy downloads its vulnerability database from a repository that is set by
// its configuration (TRIVY_DB_REPOSITORY, trivy.yaml), not the arguments, and
// it refreshes that database on a schedule. There is no local-only mode.
let path = k8s_arg(B, "path", &args, 0, ops_scan_dir)?.unwrap_or_else(|| ".".to_string());
let run = tool_run(
B,
"trivy",
None,
&["fs", "--format", "json", path.as_str()],
Keep::Head(TOOL_MAX_JSON),
TOOL_LONG_DEADLINE_SECS,
)?;
tool_json(B, run)
}
/// trivy_image(image): trivy image scan of a container image, as JSON. Trivy
/// pulls the image from its registry.
fn bi_trivy_image(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "trivy_image";
pkg_shape(B, &args, &input, 1)?;
// The image is pulled from its registry and the vulnerability database is
// downloaded from the configured repository; both are judged as unknown.
crate::safety::check_egress_unknown(
B,
"trivy pulls the image and downloads its vulnerability database from sources set by its configuration",
)?;
let image = k8s_need(B, "an image name", &args, 0, ops_image_ref)?;
let run = tool_run(
B,
"trivy",
None,
&["image", "--format", "json", image.as_str()],
Keep::Head(TOOL_MAX_JSON),
TOOL_LONG_DEADLINE_SECS,
)?;
tool_json(B, run)
}
// --- hadolint (1092) ---
/// hadolint — Lint Dockerfiles.
fn bi_hadolint_check(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "hadolint_check";
pkg_shape(B, &args, &input, 1)?;
let path = pkg_path_arg(B, &args, 0, "Dockerfile")?;
if !std::path::Path::new(&path).is_file() {
return Err(crate::safety::not_found(B, "file", &path));
}
let run = tool_run(
B,
"hadolint",
None,
&["--format", "json", path.as_str()],
Keep::Head(TOOL_MAX_JSON),
TOOL_DEADLINE_SECS,
)?;
// hadolint exits 1 when it reports findings: that is the answer.
findings_json(B, run, 1)
}
// --- shellcheck (1093) ---
/// shellcheck — Lint shell scripts.
fn bi_shellcheck_check(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let path = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => return Err(crate::safety::arg_err("shellcheck requires a script path")),
};
crate::safety::reject_option_like("shellcheck", std::slice::from_ref(&path))?;
let mut cmd = crate::safety::child_command("shellcheck");
cmd.args(["--format=json", &path]);
let output = bounded_cmd_output("shellcheck_check", "shellcheck", cmd, TOOL_DEADLINE_SECS);
match output {
// Exit 0 is clean and 1 means issues; 2 and up are failures. A file
// that did not exist exits 2 and still prints `[]`, which read as
// a clean lint. Output that was not JSON came back as a different
// shape, {success, output}.
Ok(o) if !matches!(o.status.code(), Some(0) | Some(1)) => Err(crate::safety::tool_failed(
"shellcheck",
"shellcheck",
o.status.code(),
&String::from_utf8_lossy(&o.stderr),
)),
Ok(o) => {
let stdout = String::from_utf8_lossy(&o.stdout);
serde_json::from_str::<serde_json::Value>(&stdout)
.map(json_to_value)
.map_err(|e| {
crate::safety::tool_failed(
"shellcheck",
"shellcheck --format=json",
o.status.code(),
&format!("output that is not JSON: {e}"),
)
})
}
Err(e) => Err(crate::safety::tool_missing(
"shellcheck",
"shellcheck",
&e.to_string(),
)),
}
}
// --- shfmt (1094) ---
/// shfmt — Format shell scripts.
fn bi_shfmt_format(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "shfmt_format";
pkg_shape(B, &args, &input, 1)?;
let path = k8s_need(B, "a script path", &args, 0, pkg_plain)?;
pkg_inside(B, &path)?;
// Without -w shfmt writes the formatted script to stdout and changes no file.
let run = tool_run(
B,
"shfmt",
None,
&[path.as_str()],
Keep::Head(TOOL_MAX_TEXT),
TOOL_DEADLINE_SECS,
)?;
tool_text(B, run).map(Value::Str)
}
// --- yamllint (1095) ---
/// yamllint — Lint YAML files.
fn bi_yamllint_check(args: Vec<Value>, _input: Option<Value>) -> Result<Value> {
let path = match args.first() {
Some(Value::Str(s)) => s.clone(),
_ => return Err(crate::safety::arg_err("yamllint requires a file path")),
};
crate::safety::reject_option_like("yamllint", std::slice::from_ref(&path))?;
let mut cmd = crate::safety::child_command("yamllint");
cmd.args(["-f", "parsable", &path]);
let output = bounded_cmd_output("yamllint_check", "yamllint", cmd, TOOL_DEADLINE_SECS);
match output {
Ok(o) => {
let stdout = String::from_utf8_lossy(&o.stdout);
let issues: Vec<Value> = stdout
.lines()
.filter(|l| !l.is_empty())
.map(|line| Value::Str(line.to_string()))
.collect();
// A file yamllint could not read answered {success: false,
// issues: []}: failed, with nothing wrong.
if !o.status.success() && issues.is_empty() {
return Err(crate::safety::tool_failed(
"yamllint",
"yamllint",
o.status.code(),
&String::from_utf8_lossy(&o.stderr),
));
}
let mut rec = BTreeMap::new();
rec.insert("success".to_string(), Value::Bool(o.status.success()));
rec.insert("issues".to_string(), Value::Array(issues));
Ok(Value::Record(rec))
}
Err(e) => Err(crate::safety::tool_missing(
"yamllint",
"yamllint",
&e.to_string(),
)),
}
}
// --- prettier (1096) ---
/// prettier — Format code with Prettier.
fn bi_prettier_format(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "prettier_format";
k8s_no_input(B, &input)?;
k8s_max_args(B, &args, 2)?;
let path = k8s_need(B, "a file path", &args, 0, pkg_plain)?;
pkg_inside(B, &path)?;
let write = match args.get(1) {
None => false,
Some(Value::Str(s)) if s == "--write" => true,
Some(Value::Str(s)) => {
return Err(crate::safety::arg_err(format!(
"{B}: the only option is \"--write\", got {s:?}"
)))
}
Some(other) => {
return Err(crate::safety::arg_err(format!(
"{B}: the option must be a String, got {}",
other.type_name()
)))
}
};
let mut argv: Vec<&str> = Vec::new();
if write {
argv.push("--write");
}
argv.push(path.as_str());
let run = tool_run(
B,
node_tool("prettier"),
None,
&argv,
Keep::Head(TOOL_MAX_TEXT),
PKG_RUN_DEADLINE_SECS,
)?;
pkg_done(B, run, vec![("path", Value::Str(path))])
}
// --- eslint (1097) ---
/// eslint — Lint JavaScript/TypeScript files.
fn bi_eslint_check(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "eslint_check";
pkg_shape(B, &args, &input, 1)?;
let path = pkg_path_arg(B, &args, 0, ".")?;
let run = tool_run(
B,
node_tool("eslint"),
None,
&["--format=json", path.as_str()],
Keep::Head(TOOL_MAX_JSON),
TOOL_LONG_DEADLINE_SECS,
)?;
// eslint exits 1 when it reports problems: that is the answer.
findings_json(B, run, 1)
}
// --- ruff (1098-1099) ---
/// ruff check — Lint Python files with Ruff.
fn bi_ruff_check(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "ruff_check";
pkg_shape(B, &args, &input, 1)?;
let path = pkg_path_arg(B, &args, 0, ".")?;
let mut run = tool_run(
B,
"ruff",
None,
&["check", "--output-format=json", path.as_str()],
Keep::Head(TOOL_MAX_JSON),
TOOL_DEADLINE_SECS,
)?;
// ruff exits 1 when it reports violations: that is the answer, not a
// failure. Any other nonzero exit is a failure, and tool_json reports it.
if run.code == Some(1) {
run.success = true;
}
tool_json(B, run)
}
/// ruff format — Format Python files with Ruff.
fn bi_ruff_format(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "ruff_format";
pkg_shape(B, &args, &input, 1)?;
let path = pkg_path_arg(B, &args, 0, ".")?;
let run = pkg_run(B, "ruff", &["format", path.as_str()], PKG_RUN_DEADLINE_SECS)?;
pkg_done(B, run, vec![("path", Value::Str(path))])
}
// --- black (1100) ---
/// black — Format Python code with Black.
fn bi_black_format(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "black_format";
pkg_shape(B, &args, &input, 1)?;
let path = pkg_path_arg(B, &args, 0, ".")?;
let run = pkg_run(B, "black", &[path.as_str()], PKG_RUN_DEADLINE_SECS)?;
pkg_done(B, run, vec![("path", Value::Str(path))])
}
// --- mypy (1101) ---
/// mypy — Type check Python code.
fn bi_mypy_check(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "mypy_check";
pkg_shape(B, &args, &input, 1)?;
let path = pkg_path_arg(B, &args, 0, ".")?;
let run = tool_run(
B,
"mypy",
None,
&[path.as_str()],
Keep::Head(TOOL_MAX_TEXT),
TOOL_LONG_DEADLINE_SECS,
)?;
let clean = run.code == Some(0);
let text = stdout_with_exit1(B, run)?;
let issues: Vec<Value> = text
.lines()
.map(str::trim_end)
.filter(|l| !l.is_empty() && !l.starts_with("Success:") && !l.starts_with("Found "))
.map(|l| Value::Str(l.to_string()))
.collect();
let mut rec = BTreeMap::new();
rec.insert("success".to_string(), Value::Bool(clean));
rec.insert("issues".to_string(), Value::Array(issues));
Ok(Value::Record(rec))
}
// --- pytest (1102) ---
/// pytest — Run Python tests.
fn bi_pytest_run(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
const B: &str = "pytest_run";
k8s_no_input(B, &input)?;
// Options and test selectors pass through as given. A path-shaped value
// that is not an option must lie inside the workspace.
let extra = pkg_trailing(B, &args)?;
for value in extra.iter().filter(|v| !v.starts_with('-')) {
if pkg_is_pathlike(value) {
pkg_inside(B, value)?;
}
}
let mut argv: Vec<&str> = vec!["-v"];
argv.extend(extra.iter().map(String::as_str));
let run = pkg_run(B, "pytest", &argv, PKG_RUN_DEADLINE_SECS)?;
// A failing test is a nonzero exit, so it is E_TOOL_FAILED with the output tail.
pkg_done(B, run, Vec::new())
}
// --- nodemon (1103) ---
/// nodemon — Run a .js script and restart it on changes in its directory,
/// bounded by timeout_ms.
fn bi_nodemon_run(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
tool_runs::nodemon_run(args, input)
}
#[cfg(test)]
mod fips_tests {
use super::*;
/// The FIPS posture (`safety::require_fips_hash`) had no test coverage at
/// all, despite being the enforcement point the compliance claim rests on.
/// This pins it: MD5 and SHA-1 are refused under `IRONSHELL_FIPS`, SHA-256
/// still works, and MD5 stays available for legacy interop when FIPS is off.
#[test]
fn file_checksum_honors_the_fips_gate() {
let _env = crate::safety::ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join("ae_fips_checksum_test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("sample.txt");
std::fs::write(&path, b"ironshell").unwrap();
// Repointed from `bi_file_checksum` to `bi_crypto_hash_file` when the
// former was deleted as unreachable: it duplicated this one and nothing
// dispatched to it, so the only coverage of the FIPS gate was pointed at
// a builtin no caller could reach. Argument order here is (path, algo).
let call = |algo: &str| {
bi_crypto_hash_file(
vec![
Value::Str(path.to_string_lossy().to_string()),
Value::Str(algo.to_string()),
],
None,
)
};
std::env::remove_var("IRONSHELL_FIPS");
assert!(
call("md5").is_ok(),
"MD5 stays available for legacy interop when FIPS is off"
);
std::env::set_var("IRONSHELL_FIPS", "1");
for weak in ["md5", "sha1"] {
let err = call(weak).unwrap_err().to_string();
assert!(
err.contains("E_FIPS_DISALLOWED"),
"FIPS mode must refuse {weak} with a machine-readable code; got: {err}"
);
}
assert!(call("sha256").is_ok(), "sha256 is FIPS-approved");
std::env::remove_var("IRONSHELL_FIPS");
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod declaration_narrowness {
use super::*;
use crate::signature::SIGNATURES;
/// A declaration is enforced *before* the body runs, so it is not
/// documentation -- it is the contract. Five of the first fifty-two were
/// narrower than the language and each silently deleted a form that
/// worked; two of them shipped. `reverse` declared `subject: Array`
/// refused `"abc" | reverse` from 5a54fc3 onward and passed a full green
/// gate, because one test pipes an array and another passes a string *by
/// argument* -- the broken combination was covered by neither.
///
/// None was found by review. They were found one at a time, by a corpus
/// happening to contain the right call. This asks the question directly:
/// for every declaration, every shape of value, in both calling positions,
/// does the declaration refuse something the body would have honoured?
///
/// The oracle is the body itself, via `call_with_input_inner`, which is
/// the same dispatch minus validation. That is the only oracle available:
/// there is no independent record of which forms are "meant" to work, and
/// a regex over `src/` is the name-based reasoning this whole effort
/// exists to remove.
fn probe_values() -> Vec<(&'static str, Value)> {
vec![
("Int", Value::Int(3)),
("Float", Value::Float(1.5)),
("Str", Value::Str("abc".to_string())),
("Bool", Value::Bool(true)),
("Array", Value::Array(vec![Value::Int(1), Value::Int(2)])),
(
"Record",
Value::Record(
[("a".to_string(), Value::Int(1))]
.into_iter()
.collect::<std::collections::BTreeMap<_, _>>(),
),
),
]
}
/// Whether the body produced something worth preserving.
///
/// A body that answers `Ok(Value::Null)` for nonsense is being sloppy, not
/// supporting a form -- several do exactly that (`crypto_jwt_decode`
/// returns `Ok(Null)` for a non-string). Counting those as "the
/// declaration is too narrow" would argue for keeping bugs.
fn meaningful(v: &Value) -> bool {
!matches!(v, Value::Null | Value::Error(_))
}
#[test]
fn no_declaration_refuses_a_call_its_body_would_have_honoured() {
let _env_lock = crate::safety::ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
// Put back what this sets, even when an assertion below fails. These
// are process-global: left behind, IRONSHELL_MAX_NET=0 refused the
// workflow HTTP tests that happened to run later in the same process.
struct Restore(Vec<(&'static str, Option<std::ffi::OsString>)>);
impl Drop for Restore {
fn drop(&mut self) {
for (k, v) in &self.0 {
match v {
Some(v) => std::env::set_var(k, v),
None => std::env::remove_var(k),
}
}
}
}
let _restore = Restore(
[
"IRONSHELL_MODE",
"IRONSHELL_POLICY",
"IRONSHELL_WORKSPACE",
"IRONSHELL_MAX_NET",
]
.iter()
.map(|k| (*k, std::env::var_os(k)))
.collect(),
);
let jail = std::env::temp_dir().join(format!("ae_narrow_{}", std::process::id()));
std::fs::create_dir_all(&jail).expect("create jail");
std::env::set_var("IRONSHELL_MODE", "agent");
std::env::set_var("IRONSHELL_POLICY", "strict");
std::env::set_var("IRONSHELL_WORKSPACE", &jail);
std::env::set_var("IRONSHELL_MAX_NET", "0");
let mut too_narrow: Vec<String> = Vec::new();
let (mut checked, mut refused) = (0usize, 0usize);
for sig in SIGNATURES {
// Only builtins that transform data and return. The effect gate
// would refuse the rest before the body ran, which tells us
// nothing about the declaration.
if crate::safety::effect_of(sig.name) != crate::safety::Effect::Pure {
continue;
}
for (label, v) in probe_values() {
for piped in [true, false] {
let (args, input) = if piped {
(Vec::new(), Some(v.clone()))
} else {
(vec![v.clone()], None)
};
checked += 1;
// What the declaration says.
if crate::signature::validate(sig.name, &args, input.as_ref()).is_ok() {
continue;
}
refused += 1;
// What the body would have done.
let mut env = crate::env::Env::new();
let body = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
call_with_input_inner(sig.name, args.clone(), input.clone(), &mut env)
}));
if let Ok(Ok(out)) = body {
// Did the argument make any difference? A body that
// ignores its arguments succeeds for every input by
// construction, so its success is no evidence that the
// form is supported -- and refusing that argument is
// the whole point of declaring the builtin
// parameterless. Comparing against the bare call is
// what separates "the declaration is too narrow" from
// "the body was answering calls it should not".
//
// Without this the harness reported all 17
// `project_*` declarations as regressions at the
// moment they fixed the defect they were written for.
let mut bare_env = crate::env::Env::new();
let bare = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
call_with_input_inner(sig.name, Vec::new(), None, &mut bare_env)
}));
// A piped value to a TAKES_NO_INPUT builtin is ignored
// by construction: tests/pipe_input_refused.rs derives
// that list from bodies that never read their input.
// Comparing outputs instead misread tool_schema and
// telemetry_report, whose answers change between calls.
let ignored = matches!(bare, Ok(Ok(ref b)) if *b == out)
|| (piped
&& crate::signature::TAKES_NO_INPUT
.binary_search(&sig.name)
.is_ok());
// A parameterless builtin whose own answer changes
// between identical calls (mount_info reads the live
// mount table) cannot be compared this way: the noise
// looks like the argument mattering. Refusing an
// argument is the point of declaring it parameterless,
// so a disagreement between two bare calls settles it.
// Two samples do not catch every jitter, so builtins
// whose answer is live system state are listed: their
// bodies bind `let _ = args`, so the argument provably
// cannot matter, and uptime ticks every second.
const LIVE_STATE: &[&str] = &["mount_info", "lsblk", "monitor_uptime"];
let noisy = (sig.params.is_empty() && LIVE_STATE.contains(&sig.name))
|| sig.params.is_empty() && {
let mut again_env = crate::env::Env::new();
let again =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
call_with_input_inner(
sig.name,
Vec::new(),
None,
&mut again_env,
)
}));
match (&bare, &again) {
(Ok(Ok(a)), Ok(Ok(b))) => a != b,
_ => false,
}
};
if meaningful(&out) && !ignored && !noisy {
let position = if piped { "piped" } else { "direct" };
too_narrow.push(format!(
"{}({label}, {position}) -> declaration refuses, body returns {}",
sig.name,
out.type_name()
));
}
}
}
}
}
let _ = std::fs::remove_dir_all(&jail);
// Non-vacuity, both directions. A run where nothing was refused proves
// the declarations accept everything; a run where nothing was checked
// proves the filter is broken.
assert!(
checked > 200,
"only {checked} (builtin, value, position) combinations probed; \
the filter is broken and a sweep over nothing proves nothing"
);
assert!(
refused > 20,
"only {refused} of {checked} combinations were refused by a \
declaration; either the probes are too weak or validation is not \
running, and in both cases this test cannot fail"
);
assert!(
too_narrow.is_empty(),
"{} declared signature(s) refuse a call the body would have \
honoured. A declaration is enforced before the body runs, so each \
of these deletes a form of the language:\n{:#?}\n\
({checked} combinations probed, {refused} refused)",
too_narrow.len(),
too_narrow
);
}
}
#[cfg(test)]
mod quiet_walk_tests {
use super::*;
/// A fresh directory under the system temp dir. The process id keeps two
/// test runs from sharing one.
fn scratch(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("ae-quiet-walk-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn project_loc_counts_a_non_utf8_source_file() {
let dir = scratch("loc");
std::fs::write(dir.join("main.rs"), b"fn main() {}\n").unwrap();
// Latin-1 e-acute is not UTF-8. Three lines, the last unterminated.
std::fs::write(dir.join("latin.rs"), b"// caf\xe9\nx\n\xe9").unwrap();
let total = project_loc_in(&dir);
std::fs::remove_dir_all(&dir).unwrap();
assert_eq!(total.unwrap(), 4);
}
#[cfg(unix)]
#[test]
fn walks_refuse_a_directory_they_cannot_read() {
use std::os::unix::fs::PermissionsExt;
// Root reads a mode-000 directory anyway, so there is nothing to refuse.
let running_as_root = unsafe { libc::geteuid() } == 0;
if running_as_root {
return;
}
let dir = scratch("unreadable");
let locked = dir.join("locked");
std::fs::create_dir(&locked).unwrap();
std::fs::write(locked.join("hidden.txt"), b"x").unwrap();
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
let root = Value::Str(dir.to_string_lossy().to_string());
let du = bi_fs_du(vec![root.clone()], None);
let walk = bi_fs_walk(vec![root.clone()], None);
let tree = bi_fs_tree(vec![root], None);
// Restore before removing: a mode-000 directory cannot be emptied.
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
assert!(
du.is_err(),
"fs_du returned a size for a tree it could not read"
);
assert!(
walk.is_err(),
"fs_walk returned a listing for a tree it could not read"
);
assert!(
tree.is_err(),
"fs_tree rendered an unreadable directory as a leaf"
);
}
}
#[cfg(test)]
mod quiet_fs_tests {
use super::*;
/// A scratch directory unique to this process and test. Never changes cwd.
fn scratch(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("ae-quietfs-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn path_arg(p: &std::path::Path) -> Vec<Value> {
vec![Value::Str(p.to_string_lossy().into_owned())]
}
#[test]
fn fs_stat_missing_path_is_null() {
let dir = scratch("stat_missing");
let r = bi_fs_stat(path_arg(&dir.join("nope")), None);
std::fs::remove_dir_all(&dir).ok();
assert!(matches!(r, Ok(Value::Null)));
}
#[test]
fn fs_stat_existing_file_is_a_record() {
let dir = scratch("stat_existing");
let file = dir.join("plain.txt");
std::fs::write(&file, b"abc").unwrap();
let r = bi_fs_stat(path_arg(&file), None);
std::fs::remove_dir_all(&dir).ok();
match r {
Ok(Value::Record(rec)) => assert!(matches!(rec.get("size"), Some(Value::Int(3)))),
_ => panic!("expected a record for an existing file"),
}
}
#[test]
fn fs_stat_below_a_file_is_null() {
let dir = scratch("stat_below_file");
let file = dir.join("plain");
std::fs::write(&file, b"x").unwrap();
let r = bi_fs_stat(path_arg(&file.join("x")), None);
std::fs::remove_dir_all(&dir).ok();
assert!(
matches!(r, Ok(Value::Null)),
"plain/x does not exist: {r:?}"
);
}
/// A directory the caller may not search: its children cannot be
/// checked, which is not the same as their being absent.
#[cfg(unix)]
#[test]
fn a_stat_the_os_denies_is_an_error_not_absence() {
use std::os::unix::fs::PermissionsExt;
if unsafe { libc::geteuid() } == 0 {
return;
}
let dir = scratch("stat_denied");
let locked = dir.join("locked");
std::fs::create_dir(&locked).unwrap();
std::fs::write(locked.join("f"), b"x").unwrap();
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
let child = locked.join("f");
let stat = bi_fs_stat(path_arg(&child), None);
let exists = bi_file_exists(path_arg(&child), None);
let text = read_text_lossy("project_readme", &child);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::remove_dir_all(&dir).ok();
assert!(stat.is_err(), "fs_stat: {stat:?}");
assert!(exists.is_err(), "file_exists: {exists:?}");
assert!(text.is_err(), "read_text_lossy: {text:?}");
}
#[test]
fn fs_lstat_missing_path_is_null() {
let dir = scratch("lstat_missing");
let r = bi_fs_lstat(path_arg(&dir.join("nope")), None);
std::fs::remove_dir_all(&dir).ok();
assert!(matches!(r, Ok(Value::Null)));
}
#[test]
fn fs_realpath_missing_path_is_null() {
let dir = scratch("realpath_missing");
let r = bi_fs_realpath(path_arg(&dir.join("nope")), None);
std::fs::remove_dir_all(&dir).ok();
assert!(matches!(r, Ok(Value::Null)));
}
#[test]
fn file_exists_missing_is_false() {
let dir = scratch("exists_missing");
let r = bi_file_exists(path_arg(&dir.join("nope")), None);
std::fs::remove_dir_all(&dir).ok();
match r {
Ok(Value::Record(rec)) => {
assert!(matches!(rec.get("exists"), Some(Value::Bool(false))));
assert!(matches!(rec.get("is_file"), Some(Value::Bool(false))));
assert!(matches!(rec.get("is_dir"), Some(Value::Bool(false))));
}
_ => panic!("expected a record"),
}
}
#[test]
fn file_exists_below_a_file_is_false() {
let dir = scratch("exists_below_file");
let file = dir.join("plain");
std::fs::write(&file, b"x").unwrap();
let r = bi_file_exists(path_arg(&file.join("x")), None);
std::fs::remove_dir_all(&dir).ok();
assert!(
matches!(&r, Ok(Value::Record(rec)) if matches!(rec.get("exists"), Some(Value::Bool(false)))),
"{r:?}"
);
}
#[test]
fn read_text_lossy_keeps_latin1_text() {
let dir = scratch("lossy_latin1");
let file = dir.join("README.md");
std::fs::write(&file, [b'c', b'a', b'f', 0xE9, b'\n']).unwrap();
let got = read_text_lossy("project_readme", &file);
std::fs::remove_dir_all(&dir).ok();
assert_eq!(got.unwrap().as_deref(), Some("caf\u{FFFD}\n"));
}
#[test]
fn read_text_lossy_missing_file_is_none() {
let dir = scratch("lossy_missing");
let got = read_text_lossy("project_readme", &dir.join("README.md"));
std::fs::remove_dir_all(&dir).ok();
assert!(matches!(got, Ok(None)));
}
#[test]
fn read_text_lossy_of_a_directory_is_an_error() {
let dir = scratch("lossy_dir");
let got = read_text_lossy("project_readme", &dir);
std::fs::remove_dir_all(&dir).ok();
assert!(got.is_err(), "{got:?}");
}
}
#[cfg(test)]
mod quiet_misc2_tests {
use super::*;
fn tool_run(code: Option<i32>) -> ToolRun {
ToolRun {
command: "tool".to_string(),
keep: Keep::Tail(0),
success: code == Some(0),
code,
stdout: String::new(),
stdout_truncated: false,
stderr: String::new(),
stderr_truncated: false,
}
}
#[test]
fn exit_code_is_null_without_a_code() {
let killed = run_fields(tool_run(None), "stdout", "stderr");
assert_eq!(killed.get("exit_code"), Some(&Value::Null));
let exited = run_fields(tool_run(Some(3)), "stdout", "stderr");
assert_eq!(exited.get("exit_code"), Some(&Value::Int(3)));
}
#[test]
fn ssh_port_accepts_only_1_to_65535() {
assert_eq!(ssh_port_value("2222"), Some(2222));
assert_eq!(ssh_port_value("65535"), Some(65535));
assert_eq!(ssh_port_value("0"), None);
assert_eq!(ssh_port_value("65536"), None);
assert_eq!(ssh_port_value("ssh"), None);
assert_eq!(ssh_port_value(""), None);
}
#[test]
fn env_read_classifies_unset_and_non_utf8() {
assert_eq!(
env_read("env", "X", Ok("v".to_string())).unwrap(),
Some("v".to_string())
);
assert_eq!(
env_read("env", "X", Err(std::env::VarError::NotPresent)).unwrap(),
None
);
let err = env_read(
"env_var",
"AE_BAD",
Err(std::env::VarError::NotUnicode(std::ffi::OsString::from(
"x",
))),
)
.unwrap_err();
assert!(err.to_string().contains("AE_BAD"), "{err}");
}
}
#[cfg(test)]
mod quiet_probe_tests {
use super::*;
fn obj(json: &str) -> serde_json::Map<String, serde_json::Value> {
serde_json::from_str::<serde_json::Value>(json)
.unwrap()
.as_object()
.unwrap()
.clone()
}
fn int_field(v: &Value, key: &str) -> Option<i64> {
match v {
Value::Record(r) => match r.get(key) {
Some(Value::Int(n)) => Some(*n),
_ => None,
},
_ => None,
}
}
#[test]
fn tmux_no_server_is_only_the_absent_server() {
assert!(tmux_no_server(
"no server running on /tmp/tmux-1000/default\n"
));
assert!(tmux_no_server(
"error connecting to /tmp/tmux-1001/default (No such file or directory)"
));
// A socket the caller may not open is a failure, not an empty list.
assert!(!tmux_no_server(
"error connecting to /tmp/tmux-1000/default (Permission denied)"
));
assert!(!tmux_no_server("unknown option -- Z"));
}
#[test]
fn tmux_session_lines_parse_strictly() {
let Ok(Value::Record(r)) = tmux_session_record("main:3:1") else {
panic!("a well-formed session line must parse");
};
assert!(matches!(r.get("name"), Some(Value::Str(s)) if s == "main"));
assert!(matches!(r.get("windows"), Some(Value::Int(3))));
assert!(matches!(r.get("attached"), Some(Value::Bool(true))));
assert!(tmux_session_record("main:0:0").is_ok());
assert!(tmux_session_record("main:x:1").is_err());
assert!(tmux_session_record("main:3").is_err());
assert!(tmux_session_record("main:3:yes").is_err());
}
#[test]
fn windows_counters_require_every_field() {
let row = net_counter_record(
"net_counters",
&obj(r#"{"Name":"Ethernet","ReceivedBytes":10,"SentBytes":20,"ReceivedUnicastPackets":3,"SentUnicastPackets":4}"#),
)
.unwrap();
assert_eq!(int_field(&row, "rx_bytes"), Some(10));
assert_eq!(int_field(&row, "tx_bytes"), Some(20));
assert_eq!(int_field(&row, "rx_packets"), Some(3));
assert_eq!(int_field(&row, "tx_packets"), Some(4));
// A missing counter is refused, not reported as 0 bytes.
assert!(net_counter_record(
"net_counters",
&obj(r#"{"Name":"Ethernet","ReceivedBytes":10,"ReceivedUnicastPackets":3,"SentUnicastPackets":4}"#),
)
.is_err());
// A counter that is not an integer is refused too.
assert!(net_counter_record(
"net_counters",
&obj(r#"{"Name":"Ethernet","ReceivedBytes":"10","SentBytes":20,"ReceivedUnicastPackets":3,"SentUnicastPackets":4}"#),
)
.is_err());
}
#[test]
fn socket_ports_are_real_ports() {
let o = obj(r#"{"LocalPort":8080,"RemotePort":0,"Other":"x"}"#);
assert_eq!(
net_socket_port("net_sockets", &o, "LocalPort").ok(),
Some(8080)
);
// Remote port 0 is the table's "no peer" (a listener): read as 0.
assert_eq!(
net_remote_port("net_sockets", &o, "RemotePort").ok(),
Some(0)
);
assert!(net_remote_port("net_sockets", &o, "Missing").is_err());
assert!(
net_remote_port("net_sockets", &obj(r#"{"RemotePort":70000}"#), "RemotePort").is_err()
);
assert!(net_socket_port("net_sockets", &o, "RemotePort").is_err());
assert!(net_socket_port("net_sockets", &o, "Missing").is_err());
assert!(
net_socket_port("net_sockets", &obj(r#"{"LocalPort":"80"}"#), "LocalPort").is_err()
);
assert!(
net_socket_port("net_sockets", &obj(r#"{"LocalPort":70000}"#), "LocalPort").is_err()
);
}
#[test]
fn battery_capacity_is_a_percentage_or_refused() {
let path = "/sys/class/power_supply/BAT0/capacity";
assert_eq!(battery_capacity_from(path, "87\n").ok(), Some(87));
assert_eq!(battery_capacity_from(path, " 0 ").ok(), Some(0));
assert!(battery_capacity_from(path, "").is_err());
assert!(battery_capacity_from(path, "unknown\n").is_err());
}
}
#[cfg(test)]
mod quiet_netfield_tests {
use super::*;
fn obj(v: serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
match v {
serde_json::Value::Object(m) => m,
other => panic!("not a JSON object: {other}"),
}
}
#[test]
fn required_field_is_refused_when_missing_or_not_a_string() {
let o = obj(serde_json::json!({"Name": "Ethernet", "Count": 3}));
assert_eq!(
net_req_str("net_interfaces", "powershell", &o, "Name").unwrap(),
"Ethernet"
);
assert!(net_req_str("net_interfaces", "powershell", &o, "IPAddress").is_err());
assert!(net_req_str("net_interfaces", "powershell", &o, "Count").is_err());
}
#[test]
fn optional_field_is_left_out_not_read_as_empty() {
// A virtual adapter: no MacAddress key at all.
let o = obj(serde_json::json!({
"Name": "vEthernet (Default Switch)",
"InterfaceDescription": "Hyper-V Virtual Ethernet Adapter",
"Status": "Up",
"LinkSpeed": "10 Gbps"
}));
let Value::Record(r) = net_interface_record("net_interfaces", &o).unwrap() else {
panic!("not a record");
};
assert!(!r.contains_key("mac"));
assert!(
matches!(r.get("name"), Some(Value::Str(s)) if s.as_str() == "vEthernet (Default Switch)")
);
assert!(matches!(r.get("status"), Some(Value::Str(s)) if s.as_str() == "Up"));
assert!(r.contains_key("description"));
assert!(r.contains_key("speed"));
}
#[test]
fn identity_field_missing_refuses_the_whole_record() {
let o = obj(serde_json::json!({"InterfaceDescription": "x"}));
assert!(net_interface_record("net_interfaces", &o).is_err());
}
#[test]
fn windows_state_names_and_unnamed_codes() {
assert_eq!(windows_state_name(5), "ESTABLISHED");
assert_eq!(windows_state_name(2), "LISTEN");
assert_eq!(windows_state_name(100), "BOUND");
assert_eq!(windows_state_name(99), "UNKNOWN");
}
#[test]
fn macos_netstat_endpoints_read_as_addr_colon_port() {
assert_eq!(macos_netstat_endpoint("127.0.0.1.49817"), "127.0.0.1:49817");
assert_eq!(macos_netstat_endpoint("*.*"), "*:*");
assert_eq!(macos_netstat_endpoint("*.22"), "*:22");
assert_eq!(macos_netstat_endpoint("::1.5353"), "[::1]:5353");
}
#[test]
fn macos_netstat_rows_list_the_listener_on_its_port() {
// The shape of `netstat -an -p tcp` on macOS: two header lines, then
// one row per socket.
let sample = [
"Active Internet connections (including servers)",
"Proto Recv-Q Send-Q Local Address Foreign Address (state)",
"tcp4 0 0 127.0.0.1.49817 *.* LISTEN",
"tcp4 0 0 127.0.0.1.49817 127.0.0.1.50012 ESTABLISHED",
]
.join("\n");
let rows = macos_netstat_rows("net_ports", &sample).unwrap();
assert_eq!(rows.len(), 2);
let field = |i: usize, key: &str| match &rows[i] {
Value::Record(r) => match r.get(key) {
Some(Value::Str(s)) => s.clone(),
other => panic!("no {key} in row {i}: {other:?}"),
},
other => panic!("row {i} is not a record: {other:?}"),
};
assert_eq!(field(0, "local"), "127.0.0.1:49817");
assert_eq!(field(0, "peer"), "*:*");
assert!(net_is_listening(&rows[0]));
assert!(!net_is_listening(&rows[1]));
}
}
#[cfg(test)]
mod quiet_svc_tests {
use super::*;
fn str_field(v: &Value, key: &str) -> Option<String> {
match v {
Value::Record(r) => match r.get(key) {
Some(Value::Str(s)) => Some(s.clone()),
_ => None,
},
_ => None,
}
}
#[test]
fn windows_array_of_two_services_becomes_two_records() {
let json = r#"[{"Name":"Spooler","DisplayName":"Print Spooler","Status":4},{"Name":"BITS","DisplayName":"Background Intelligent Transfer Service","Status":1}]"#;
let recs = win_service_records("svc_list", json).unwrap();
assert_eq!(recs.len(), 2);
assert_eq!(str_field(&recs[0], "name").as_deref(), Some("Spooler"));
assert_eq!(str_field(&recs[0], "status").as_deref(), Some("running"));
assert_eq!(str_field(&recs[1], "name").as_deref(), Some("BITS"));
assert_eq!(str_field(&recs[1], "status").as_deref(), Some("stopped"));
}
#[test]
fn windows_single_object_becomes_one_record() {
let json = r#"{"Name":"sshd","DisplayName":"OpenSSH","Status":2}"#;
let recs = win_service_records("svc_list", json).unwrap();
assert_eq!(recs.len(), 1);
assert_eq!(str_field(&recs[0], "name").as_deref(), Some("sshd"));
assert_eq!(
str_field(&recs[0], "status").as_deref(),
Some("start_pending")
);
}
#[test]
fn windows_empty_stdout_is_no_services() {
assert!(win_service_records("svc_list", "").unwrap().is_empty());
assert!(win_service_records("svc_list", " \r\n\t ")
.unwrap()
.is_empty());
}
#[test]
fn windows_garbage_is_refused() {
assert!(win_service_records("svc_list", "Get-Service: access denied").is_err());
assert!(win_service_records("svc_list", "42").is_err());
assert!(win_service_records("svc_list", "\"text\"").is_err());
}
#[test]
fn windows_item_missing_name_is_refused() {
let json = r#"[{"Name":"ok","Status":4},{"DisplayName":"no name","Status":4}]"#;
assert!(win_service_records("svc_list", json).is_err());
}
#[test]
fn windows_item_that_is_not_an_object_is_refused() {
let json = r#"[{"Name":"ok"},"stray"]"#;
assert!(win_service_records("svc_list", json).is_err());
}
#[test]
fn systemd_array_of_objects_passes_through_as_array() {
let json = r#"[{"unit":"cron.service","load":"loaded","active":"active","sub":"running","description":"Regular jobs"}]"#;
match systemd_units("svc_list", json).unwrap() {
Value::Array(items) => {
assert_eq!(items.len(), 1);
assert_eq!(
str_field(&items[0], "unit").as_deref(),
Some("cron.service")
);
}
other => panic!("expected an array, got {}", other.type_name()),
}
}
#[test]
fn systemd_empty_or_garbage_or_non_array_is_refused() {
assert!(systemd_units("svc_list", "").is_err());
assert!(systemd_units("svc_list", "Failed to connect to bus").is_err());
assert!(systemd_units("svc_list", r#"{"unit":"x.service"}"#).is_err());
assert!(systemd_units("svc_list", r#"[1, 2]"#).is_err());
}
}
#[cfg(test)]
mod capped_read_tests {
use super::*;
fn scratch(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("ae-capped-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn code(e: &anyhow::Error) -> Option<crate::safety::ErrorCode> {
e.downcast_ref::<crate::safety::SafetyError>()
.map(|se| se.code)
}
#[test]
fn a_file_within_the_limit_is_read_whole() {
let dir = scratch("within");
let p = dir.join("f.txt");
std::fs::write(&p, "abcd").unwrap();
assert_eq!(read_text_capped_at("t", &p, 4).unwrap(), "abcd");
}
#[test]
fn a_file_over_the_limit_is_a_budget_refusal() {
let dir = scratch("over");
let p = dir.join("f.txt");
std::fs::write(&p, "abcde").unwrap();
let e = read_text_capped_at("t", &p, 4).unwrap_err();
assert_eq!(code(&e), Some(crate::safety::ErrorCode::BudgetExceeded));
}
#[test]
fn a_missing_file_is_not_found() {
let dir = scratch("missing");
let e = read_text_capped_at("t", &dir.join("none"), 4).unwrap_err();
assert_eq!(code(&e), Some(crate::safety::ErrorCode::NotFound));
}
#[test]
fn the_file_size_check_is_coded() {
let e = crate::security::check_file_size_limit(u64::MAX).unwrap_err();
assert_eq!(code(&e), Some(crate::safety::ErrorCode::BudgetExceeded));
}
}
#[cfg(test)]
mod capped_reads_tests {
use super::*;
fn temp_file(name: &str, body: &str) -> String {
let path = std::env::temp_dir().join(format!("ae-cr-{}-{name}", std::process::id()));
std::fs::write(&path, body).expect("write temp file");
path.display().to_string()
}
#[test]
fn get_content_returns_every_line_of_a_small_file() {
let path = temp_file("get-content.txt", "alpha\nbeta\n");
let result = bi_get_content(vec![Value::Str(path.clone())], None);
let _ = std::fs::remove_file(&path);
match result.expect("get_content reads a small file") {
Value::Array(lines) => {
let lines: Vec<String> = lines
.into_iter()
.map(|v| match v {
Value::Str(s) => s,
other => panic!("expected a string line, got {other:?}"),
})
.collect();
assert_eq!(lines, vec!["alpha".to_string(), "beta".to_string()]);
}
other => panic!("expected an array of lines, got {other:?}"),
}
}
#[test]
fn code_todos_still_reads_a_small_file() {
let path = temp_file("code-todos.rs", "fn a() {}\n// TODO: later\n");
let result = bi_code_todos(vec![Value::Str(path.clone())], None);
let _ = std::fs::remove_file(&path);
assert!(
result.is_ok(),
"code_todos should read the file: {result:?}"
);
}
#[test]
fn get_content_on_a_missing_file_is_still_not_found() {
let path = std::env::temp_dir()
.join(format!("ae-cr-{}-missing.txt", std::process::id()))
.display()
.to_string();
let err =
bi_get_content(vec![Value::Str(path)], None).expect_err("a missing file is an error");
let safety = err
.downcast_ref::<crate::safety::SafetyError>()
.expect("the error carries a safety code");
assert!(matches!(safety.code, crate::safety::ErrorCode::NotFound));
}
}
#[cfg(test)]
mod walk_cap_tests {
use super::*;
use crate::safety::ErrorCode;
fn is_budget(e: &anyhow::Error) -> bool {
e.downcast_ref::<crate::safety::SafetyError>()
.is_some_and(|s| matches!(s.code, ErrorCode::BudgetExceeded))
}
/// A fresh directory under the temp dir, holding `n` files.
fn fixture(name: &str, n: usize) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("ae-wc-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for i in 0..n {
std::fs::write(dir.join(format!("f{i}.txt")), b"x").unwrap();
}
dir
}
#[test]
fn push_capped_refuses_the_entry_past_the_cap() {
let mut out = Vec::new();
for i in 0..3 {
push_capped("find", &mut out, Value::Int(i), 3).unwrap();
}
let e = push_capped("find", &mut out, Value::Int(3), 3).unwrap_err();
assert!(is_budget(&e));
assert_eq!(out.len(), 3);
}
#[test]
fn walk_refuses_when_entries_exceed_the_cap() {
let dir = fixture("over", 5);
let result = walk_entries_capped(&dir, 100, 3);
let _ = std::fs::remove_dir_all(&dir);
assert!(is_budget(&result.unwrap_err()));
}
#[test]
fn walk_succeeds_when_the_cap_is_not_exceeded() {
let dir = fixture("under", 5);
let result = walk_entries_capped(&dir, 100, 5);
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(result.unwrap().len(), 5);
}
}
#[cfg(test)]
mod range_cap_tests {
use super::*;
use crate::safety::ErrorCode;
fn ints(args: Vec<Value>) -> Result<Vec<i64>> {
match bi_range(args, None)? {
Value::Array(items) => Ok(items
.into_iter()
.map(|v| match v {
Value::Int(n) => n,
other => panic!("expected Int, got {:?}", other),
})
.collect()),
other => panic!("expected Array, got {:?}", other),
}
}
fn code_of(err: anyhow::Error) -> ErrorCode {
err.downcast_ref::<crate::safety::SafetyError>()
.map(|s| s.code)
.expect("a SafetyError")
}
#[test]
fn plain_ranges_are_unchanged() {
assert_eq!(ints(vec![Value::Int(5)]).unwrap(), vec![0, 1, 2, 3, 4]);
assert_eq!(
ints(vec![Value::Int(0), Value::Int(10), Value::Int(3)]).unwrap(),
vec![0, 3, 6, 9]
);
assert_eq!(
ints(vec![Value::Int(5), Value::Int(0), Value::Int(-2)]).unwrap(),
vec![5, 3, 1]
);
}
#[test]
fn oversized_range_is_refused_before_allocation() {
match bi_range(vec![Value::Int(0), Value::Int(20_000_000)], None) {
Ok(_) => panic!("a 20,000,000 element range must be refused"),
Err(e) => assert_eq!(code_of(e), ErrorCode::BudgetExceeded),
}
}
#[test]
fn zero_step_is_a_bad_argument() {
match bi_range(vec![Value::Int(0), Value::Int(1), Value::Int(0)], None) {
Ok(_) => panic!("a zero step must be refused"),
Err(e) => assert_eq!(code_of(e), ErrorCode::BadArg),
}
}
#[test]
fn range_near_the_i64_limits_does_not_overflow() {
assert_eq!(
ints(vec![
Value::Int(9_223_372_036_854_775_800),
Value::Int(9_223_372_036_854_775_807),
Value::Int(100),
])
.unwrap(),
vec![9_223_372_036_854_775_800]
);
assert_eq!(
ints(vec![
Value::Int(-9_223_372_036_854_775_803),
Value::Int(-9_223_372_036_854_775_808),
Value::Int(-100),
])
.unwrap(),
vec![-9_223_372_036_854_775_803]
);
}
}
#[cfg(test)]
mod capped_bytes_tests {
use super::*;
fn scratch(name: &str, body: &[u8]) -> std::path::PathBuf {
let path = std::env::temp_dir().join(format!("ae-cb-{}-{name}", std::process::id()));
std::fs::write(&path, body).expect("write temp file");
path
}
fn budget_code(e: &anyhow::Error) -> Option<crate::safety::ErrorCode> {
e.downcast_ref::<crate::safety::SafetyError>()
.map(|s| s.code)
}
#[test]
fn read_bytes_capped_at_under_at_and_over_the_limit() {
let under = scratch("under", &[7u8; 15]);
let at = scratch("at", &[7u8; 16]);
let over = scratch("over", &[7u8; 17]);
assert_eq!(
read_bytes_capped_at("t", &under, 16).unwrap(),
vec![7u8; 15]
);
assert_eq!(read_bytes_capped_at("t", &at, 16).unwrap(), vec![7u8; 16]);
let e = read_bytes_capped_at("t", &over, 16).unwrap_err();
assert_eq!(
budget_code(&e),
Some(crate::safety::ErrorCode::BudgetExceeded)
);
for p in [under, at, over] {
let _ = std::fs::remove_file(p);
}
}
#[test]
fn read_bytes_capped_at_missing_file_is_not_found() {
let missing =
std::env::temp_dir().join(format!("ae-cb-{}-no-such-file", std::process::id()));
let e = read_bytes_capped_at("t", &missing, 16).unwrap_err();
assert!(capped_read_not_found(&e), "{e:#}");
assert_eq!(budget_code(&e), Some(crate::safety::ErrorCode::NotFound));
}
#[test]
fn read_text_capped_at_rejects_invalid_utf8() {
let bad = scratch("bad-utf8", &[b'a', 0xff, 0xfe, b'b']);
let good = scratch("good-utf8", "héllo".as_bytes());
assert!(read_text_capped_at("t", &bad, 1024).is_err());
assert_eq!(read_text_capped_at("t", &good, 1024).unwrap(), "héllo");
for p in [bad, good] {
let _ = std::fs::remove_file(p);
}
}
#[test]
fn read_text_lossy_under_a_regular_file_is_absent() {
let f = std::env::temp_dir().join(format!("ae-cb-{}-plainfile", std::process::id()));
std::fs::write(&f, "x").unwrap();
let under = f.join("README.md");
let got = read_text_lossy("t", &under);
let _ = std::fs::remove_file(&f);
assert!(matches!(got, Ok(None)), "{got:?}");
}
}
#[cfg(test)]
mod glob_cap_tests {
use crate::posix::{glob_paths, glob_paths_capped};
fn dir_with_five(name: &str) -> std::path::PathBuf {
let root = std::env::temp_dir().join(format!("ae-gc-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
for f in ["a.txt", "b.txt", "c.txt", "d.txt", "e.txt"] {
std::fs::write(root.join(f), "").unwrap();
}
root
}
#[test]
fn capped_glob_refuses_over_and_matches_below() {
let root = dir_with_five("caps");
let base = root.to_string_lossy().replace('\\', "/");
let pattern = format!("{base}/*.txt");
let (none, over) = glob_paths_capped(&pattern, 3);
assert!(over, "5 matches must be over a cap of 3");
assert!(none.is_empty(), "nothing is returned once over: {none:?}");
let (all, over) = glob_paths_capped(&pattern, 5);
assert!(!over);
assert_eq!(all.len(), 5);
assert_eq!(all, glob_paths(&pattern));
let names: Vec<&str> = all.iter().map(|p| p.rsplit('/').next().unwrap()).collect();
assert_eq!(names, ["a.txt", "b.txt", "c.txt", "d.txt", "e.txt"]);
// The recursive form gathers the same names.
let recursive = format!("{base}/**/*.txt");
let (r, over) = glob_paths_capped(&recursive, 5);
assert!(!over);
assert_eq!(r, glob_paths(&recursive));
assert_eq!(r.len(), 5);
let _ = std::fs::remove_dir_all(&root);
}
}
#[cfg(test)]
mod atomic_write_tests {
use super::{gzip_file_capped, write_file_atomically};
use std::path::{Path, PathBuf};
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("ae-aw-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn has_no_temporary(dir: &Path) -> bool {
std::fs::read_dir(dir).unwrap().all(|entry| {
!entry
.unwrap()
.file_name()
.to_string_lossy()
.starts_with(".ironshell_tmp_")
})
}
#[test]
fn overwrite_keeps_content_and_leaves_no_temporary() {
let dir = scratch("content");
let target = dir.join("config.txt");
std::fs::write(&target, "old").unwrap();
write_file_atomically("file_write", target.to_str().unwrap(), b"new line\n").unwrap();
assert_eq!(std::fs::read_to_string(&target).unwrap(), "new line\n");
assert!(has_no_temporary(&dir));
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn overwrite_keeps_execute_mode() {
use std::os::unix::fs::PermissionsExt;
let dir = scratch("mode");
let target = dir.join("run.sh");
std::fs::write(&target, "#!/bin/sh\necho old\n").unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
write_file_atomically(
"file_write",
target.to_str().unwrap(),
b"#!/bin/sh\necho new\n",
)
.unwrap();
let mode = std::fs::metadata(&target).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o755);
assert_eq!(
std::fs::read_to_string(&target).unwrap(),
"#!/bin/sh\necho new\n"
);
assert!(has_no_temporary(&dir));
let _ = std::fs::remove_dir_all(&dir);
}
// Decision: a read-only file is refused, as the OS refuses it. The rename
// onto a read-only target fails, the content is unchanged, and no temporary
// is left behind (its read-only copy is made writable before removal).
#[cfg(windows)]
#[test]
#[allow(clippy::permissions_set_readonly_false)] // cleanup only: clears the attribute so the directory can be removed
fn overwrite_of_read_only_file_is_refused() {
let dir = scratch("readonly");
let target = dir.join("locked.txt");
std::fs::write(&target, "old").unwrap();
let mut permissions = std::fs::metadata(&target).unwrap().permissions();
permissions.set_readonly(true);
std::fs::set_permissions(&target, permissions).unwrap();
let result = write_file_atomically("file_write", target.to_str().unwrap(), b"new");
assert!(result.is_err());
assert_eq!(std::fs::read_to_string(&target).unwrap(), "old");
assert!(has_no_temporary(&dir));
let mut permissions = std::fs::metadata(&target).unwrap().permissions();
permissions.set_readonly(false);
std::fs::set_permissions(&target, permissions).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn write_through_symlink_updates_target_and_keeps_link() {
let dir = scratch("symlink");
let target = dir.join("real.txt");
let link = dir.join("link.txt");
std::fs::write(&target, "old").unwrap();
std::os::unix::fs::symlink(&target, &link).unwrap();
write_file_atomically("file_write", link.to_str().unwrap(), b"new").unwrap();
assert_eq!(std::fs::read_to_string(&target).unwrap(), "new");
assert!(std::fs::symlink_metadata(&link)
.unwrap()
.file_type()
.is_symlink());
assert_eq!(std::fs::read_to_string(&link).unwrap(), "new");
assert!(has_no_temporary(&dir));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn gzip_refuses_existing_output_and_leaves_it_untouched() {
let dir = scratch("gzip");
let input = dir.join("data.txt");
let output = dir.join("data.txt.gz");
std::fs::write(&input, "payload").unwrap();
std::fs::write(&output, "keep me").unwrap();
let result = gzip_file_capped(
"gzip_compress",
input.to_str().unwrap(),
output.to_str().unwrap(),
false,
1024,
);
assert!(result.is_err());
assert_eq!(std::fs::read_to_string(&output).unwrap(), "keep me");
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod archive_prescan_tests {
use super::*;
fn entries(items: &[(&str, Option<&str>)]) -> Vec<(String, Option<String>)> {
items
.iter()
.map(|(n, t)| (n.to_string(), t.map(str::to_string)))
.collect()
}
fn check(items: &[(&str, Option<&str>)]) -> bool {
check_archive_entries("tar_extract", entries(items).into_iter()).is_ok()
}
#[test]
fn relative_names_inside_the_destination_are_allowed() {
assert!(!archive_entry_escapes("a/b.txt"));
assert!(!archive_entry_escapes("a"));
assert!(!archive_entry_escapes("a/"));
assert!(!archive_entry_escapes("./a/b"));
// `..` is only special as a whole component.
assert!(!archive_entry_escapes("a..b/c"));
assert!(!archive_entry_escapes("dir/..hidden"));
}
#[test]
fn escaping_names_are_refused() {
for name in [
"../x",
"a/../../x",
"/etc/passwd",
"\\\\server\\share\\x",
"C:\\x",
"C:x",
"a\\..\\..\\x",
"..",
"a/..",
] {
assert!(archive_entry_escapes(name), "{name} should be refused");
}
}
#[test]
fn symlink_targets_are_checked_like_names() {
assert!(check(&[("a", Some("b"))]));
assert!(check(&[("a", Some("sub/b"))]));
assert!(!check(&[("a", Some("../../etc"))]));
assert!(!check(&[("a", Some("/etc"))]));
assert!(check(&[("a/b.txt", None)]));
}
#[test]
fn an_xz_tar_is_listed_through_the_tar_tool() {
let dir =
std::env::temp_dir().join(format!("ae_prescan_xz_{}", uuid::Uuid::new_v4().simple()));
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src").join("a.txt"), "a").unwrap();
let archive = dir.join("t.tar.xz");
let made = crate::safety::child_command("tar")
.arg("-cJf")
.arg(&archive)
.arg("-C")
.arg(dir.join("src"))
.arg("a.txt")
.status()
.map(|s| s.success())
.unwrap_or(false);
if !made {
let _ = std::fs::remove_dir_all(&dir);
eprintln!("skipped: this tar cannot write xz");
return;
}
let names = tar_archive_entries("tar_extract", archive.to_str().unwrap());
let _ = std::fs::remove_dir_all(&dir);
let names = names.unwrap();
assert!(names
.iter()
.any(|(n, t)| n.trim_end_matches('/') == "a.txt" && t.is_none()));
}
#[test]
fn an_xz_tar_has_its_link_targets_checked() {
use std::io::Write;
let dir = std::env::temp_dir().join(format!(
"ae_prescan_xzlink_{}",
uuid::Uuid::new_v4().simple()
));
std::fs::create_dir_all(&dir).unwrap();
let mut raw = Vec::new();
{
let mut b = tar::Builder::new(&mut raw);
let mut h = tar::Header::new_gnu();
h.set_path("a").unwrap();
h.set_entry_type(tar::EntryType::Symlink);
h.set_link_name("../../etc").unwrap();
h.set_size(0);
h.set_cksum();
b.append(&h, std::io::empty()).unwrap();
b.finish().unwrap();
}
let xz = crate::safety::child_command("xz")
.arg("-c")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn();
let Ok(mut xz) = xz else {
let _ = std::fs::remove_dir_all(&dir);
eprintln!("skipped: no xz on PATH");
return;
};
let mut stdin = xz.stdin.take().unwrap();
let feed = std::thread::spawn(move || stdin.write_all(&raw));
let out = xz.wait_with_output().unwrap();
feed.join().unwrap().unwrap();
assert!(out.status.success());
let archive = dir.join("evil.tar.xz");
std::fs::write(&archive, &out.stdout).unwrap();
let listed = tar_archive_entries("tar_extract", archive.to_str().unwrap());
let _ = std::fs::remove_dir_all(&dir);
let listed = listed.unwrap();
assert_eq!(listed, entries(&[("a", Some("../../etc"))]));
assert!(check_archive_entries("tar_extract", listed.into_iter()).is_err());
}
#[test]
fn a_corrupt_xz_stream_is_a_tool_failure() {
let dir = std::env::temp_dir().join(format!(
"ae_prescan_xzbad_{}",
uuid::Uuid::new_v4().simple()
));
std::fs::create_dir_all(&dir).unwrap();
let archive = dir.join("bad.tar.xz");
// xz magic, then garbage.
std::fs::write(&archive, [0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00, 1, 2, 3, 4]).unwrap();
let mut xz = crate::safety::child_command("xz");
xz.arg("--version");
let have_xz = bounded_cmd_output("tar_extract", "xz", xz, PROBE_DEADLINE_SECS).is_ok();
let listed = tar_archive_entries("tar_extract", archive.to_str().unwrap());
let _ = std::fs::remove_dir_all(&dir);
if !have_xz {
eprintln!("skipped: no xz on PATH");
return;
}
assert!(listed.is_err());
}
#[test]
fn one_escaping_entry_refuses_the_whole_archive() {
assert!(!check(&[
("ok.txt", None),
("../x", None),
("ok2.txt", None)
]));
assert!(check(&[("ok.txt", None), ("dir/ok2.txt", None)]));
}
#[test]
fn too_many_entries_are_refused() {
let items = (0..=WALK_MAX_ENTRIES).map(|_| ("a".to_string(), None));
assert!(check_archive_entries("tar_extract", items).is_err());
}
#[test]
fn a_declared_total_over_the_budget_is_budget_exceeded() {
assert!(check_archive_budget("tar_extract", ARCHIVE_EXTRACT_MAX_BYTES).is_ok());
let err = check_archive_budget("tar_extract", ARCHIVE_EXTRACT_MAX_BYTES + 1)
.expect_err("one byte over must be refused");
assert!(format!("{err}").contains("E_BUDGET_EXCEEDED"), "{err}");
}
#[test]
fn a_zip_listing_totals_its_declared_sizes() {
use std::io::Write;
let dir =
std::env::temp_dir().join(format!("ae_budget_zip_{}", uuid::Uuid::new_v4().simple()));
std::fs::create_dir_all(&dir).unwrap();
let archive = dir.join("sizes.zip");
{
let file = std::fs::File::create(&archive).unwrap();
let mut w = zip::ZipWriter::new(file);
let opts = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
w.start_file("a.bin", opts).unwrap();
w.write_all(b"abc").unwrap();
w.start_file("b.bin", opts).unwrap();
w.write_all(b"defgh").unwrap();
w.finish().unwrap();
}
let listed = zip_archive_entries("zip_extract", archive.to_str().unwrap());
let _ = std::fs::remove_dir_all(&dir);
let (names, declared) = listed.unwrap();
assert_eq!(names.len(), 2);
assert_eq!(declared, 8);
}
#[test]
fn a_tar_listing_totals_its_declared_sizes() {
let dir =
std::env::temp_dir().join(format!("ae_budget_tar_{}", uuid::Uuid::new_v4().simple()));
std::fs::create_dir_all(&dir).unwrap();
let archive = dir.join("sizes.tar");
{
let file = std::fs::File::create(&archive).unwrap();
let mut b = tar::Builder::new(file);
for (name, body) in [("a.bin", &b"abc"[..]), ("b.bin", &b"defgh"[..])] {
let mut h = tar::Header::new_gnu();
h.set_path(name).unwrap();
h.set_size(body.len() as u64);
h.set_mode(0o644);
h.set_cksum();
b.append(&h, body).unwrap();
}
b.finish().unwrap();
}
let listed = tar_archive_listing("tar_extract", archive.to_str().unwrap());
let _ = std::fs::remove_dir_all(&dir);
let (names, declared) = listed.unwrap();
assert_eq!(names.len(), 2);
assert_eq!(declared, 8);
}
#[test]
fn zip_extract_refuses_a_zip_slip_member_before_writing() {
use std::io::Write;
let root = std::env::temp_dir().join(format!("ae-ap-{}-zip", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let dest = root.join("dest");
std::fs::create_dir_all(&dest).unwrap();
let archive = root.join("evil.zip");
{
let file = std::fs::File::create(&archive).unwrap();
let mut w = zip::ZipWriter::new(file);
let opts = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
w.start_file("../evil.txt", opts).unwrap();
w.write_all(b"pwned").unwrap();
w.finish().unwrap();
}
let args = vec![
Value::Str(archive.display().to_string()),
Value::Str(dest.display().to_string()),
];
let result = bi_zip_extract(args, None);
assert!(result.is_err(), "zip-slip archive must be refused");
assert!(
!root.join("evil.txt").exists(),
"nothing may land outside dest"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn tar_extract_refuses_a_symlink_to_outside_before_writing() {
let root = std::env::temp_dir().join(format!("ae-ap-{}-tar", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let dest = root.join("dest");
std::fs::create_dir_all(&dest).unwrap();
let archive = root.join("evil.tar");
{
let file = std::fs::File::create(&archive).unwrap();
let mut b = tar::Builder::new(file);
let mut h = tar::Header::new_gnu();
h.set_path("a").unwrap();
h.set_entry_type(tar::EntryType::Symlink);
h.set_link_name("../../etc").unwrap();
h.set_size(0);
h.set_cksum();
b.append(&h, std::io::empty()).unwrap();
b.finish().unwrap();
}
let args = vec![
Value::Str(archive.display().to_string()),
Value::Str(dest.display().to_string()),
];
let result = bi_tar_extract(args, None);
assert!(result.is_err(), "escaping symlink must be refused");
assert!(!dest.join("a").exists(), "nothing may be written");
let _ = std::fs::remove_dir_all(&root);
}
}
#[cfg(test)]
mod apply_preflight_tests {
use super::{apply_op_refusal, apply_preflight_refusal};
type Op = (String, String, Option<String>, Option<String>);
fn op(o: &str, p: &str, d: Option<&str>) -> Op {
(o.to_string(), p.to_string(), None, d.map(str::to_string))
}
// These use enforce_jail = false, so they never read the workspace root or
// the environment, and they are safe to run in parallel.
#[test]
fn copy_onto_its_own_source_is_refused() {
let refusal = apply_op_refusal("copy", "a.txt", Some("a.txt"), false);
assert_eq!(
refusal.as_deref(),
Some("copy a.txt: destination is the source")
);
}
#[test]
fn move_to_a_distinct_destination_is_allowed() {
assert_eq!(
apply_op_refusal("move", "a.txt", Some("b.txt"), false),
None
);
}
#[test]
fn write_has_no_destination_to_check() {
assert_eq!(apply_op_refusal("write", "a.txt", None, false), None);
}
#[test]
fn plan_refusal_is_the_first_refused_op() {
let ops = vec![
op("write", "a.txt", None),
op("copy", "b.txt", Some("b.txt")),
op("copy", "c.txt", Some("c.txt")),
];
assert_eq!(
apply_preflight_refusal(&ops, false).as_deref(),
Some("copy b.txt: destination is the source")
);
}
#[test]
fn delete_then_write_the_same_path_is_a_clean_plan() {
let ops = vec![
op("rm", "a.txt", None),
op("write", "a.txt", None),
op("mkdir", "d", None),
];
assert_eq!(apply_preflight_refusal(&ops, false), None);
}
}
#[cfg(test)]
mod coded_errors2_tests {
use super::*;
fn code_of(e: &anyhow::Error) -> Option<crate::safety::ErrorCode> {
e.downcast_ref::<crate::safety::SafetyError>()
.map(|s| s.code)
}
fn scratch_path(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"ae_coded2_{}_{}",
name,
uuid::Uuid::new_v4().simple()
))
}
#[test]
fn ls_missing_path_is_coded_not_found() {
let missing = scratch_path("ls_missing");
let e = bi_ls(vec![Value::Str(missing.display().to_string())], None).unwrap_err();
assert_eq!(code_of(&e), Some(crate::safety::ErrorCode::NotFound));
}
#[test]
fn ls_on_a_plain_file_is_coded() {
// read_dir on a file fails with an OS error; before the fix that error
// reached the caller with no code attached.
let file = scratch_path("ls_file");
fs::write(&file, b"not a directory").unwrap();
let result = bi_ls(vec![Value::Str(file.display().to_string())], None);
let _ = fs::remove_file(&file);
let e = result.unwrap_err();
assert!(
code_of(&e).is_some(),
"ls on a file must carry an error code, got: {e}"
);
}
#[test]
fn save_json_into_a_missing_directory_is_coded() {
let path = scratch_path("nodir").join("out.json").display().to_string();
let args = vec![Value::Str(path.clone()), Value::Str("payload".to_string())];
let e = bi_save_json(args, None).unwrap_err();
assert!(
code_of(&e).is_some(),
"save_json into a missing directory must carry an error code, got: {e}"
);
assert!(!std::path::Path::new(&path).exists());
}
#[test]
fn ai_set_default_unknown_provider_is_bad_arg() {
let e = bi_ai_set_default(vec![Value::Str("no-such-provider-xyz".to_string())], None)
.unwrap_err();
assert_eq!(code_of(&e), Some(crate::safety::ErrorCode::BadArg));
}
}
#[cfg(test)]
mod clip_deadline_tests {
// Only harmless stand-in programs run here. No clipboard tool is ever
// started, and the real clipboard is never touched.
use super::*;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
fn piped(mut cmd: Command) -> std::process::Child {
cmd.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null());
cmd.spawn().expect("the stand-in program should start")
}
/// Reads all of its input and exits 0.
#[cfg(unix)]
fn reads_stdin_and_exits() -> Command {
let mut c = crate::safety::child_command("sh");
c.args(["-c", "cat >/dev/null"]);
c
}
#[cfg(windows)]
fn reads_stdin_and_exits() -> Command {
let mut c = crate::safety::child_command("cmd");
c.args(["/C", "more"]);
c
}
/// Ignores its input and runs for about five seconds.
#[cfg(unix)]
fn ignores_stdin_and_sleeps() -> Command {
let mut c = crate::safety::child_command("sh");
c.args(["-c", "sleep 5"]);
c
}
#[cfg(windows)]
fn ignores_stdin_and_sleeps() -> Command {
let mut c = crate::safety::child_command("cmd");
c.args(["/C", "ping -n 6 127.0.0.1"]);
c
}
/// Exits 0 at once, without reading its input.
#[cfg(unix)]
fn exits_without_reading() -> Command {
let mut c = crate::safety::child_command("sh");
c.args(["-c", "exit 0"]);
c
}
#[cfg(windows)]
fn exits_without_reading() -> Command {
let mut c = crate::safety::child_command("cmd");
c.args(["/C", "exit 0"]);
c
}
fn safety_code(e: &anyhow::Error) -> Option<crate::safety::ErrorCode> {
e.downcast_ref::<crate::safety::SafetyError>()
.map(|se| se.code)
}
#[test]
fn a_helper_that_reads_its_input_and_exits_succeeds() {
let child = piped(reads_stdin_and_exits());
let status = feed_stdin_and_wait("t", "stand-in", child, vec![b'x'; 4096], 20)
.expect("a helper that reads its input should not be an error");
assert!(status.success());
}
#[test]
fn a_helper_that_ignores_its_input_is_stopped_at_the_deadline() {
let child = piped(ignores_stdin_and_sleeps());
let start = Instant::now();
let err = feed_stdin_and_wait("t", "stand-in", child, b"hello".to_vec(), 1)
.expect_err("a helper still running at the deadline must be stopped");
let took = start.elapsed();
assert!(took < Duration::from_secs(4), "took {took:?}");
assert_eq!(
safety_code(&err),
Some(crate::safety::ErrorCode::BudgetExceeded)
);
}
#[test]
fn a_large_input_to_a_helper_that_exits_at_once_returns() {
let child = piped(exits_without_reading());
let start = Instant::now();
let res = feed_stdin_and_wait("t", "stand-in", child, vec![0u8; 4 << 20], 20);
assert!(start.elapsed() < Duration::from_secs(15));
match res {
Ok(status) => assert!(status.success()),
Err(e) => assert_ne!(
safety_code(&e),
Some(crate::safety::ErrorCode::BudgetExceeded),
"a helper that exited at once must not be reported as a timeout"
),
}
}
}
#[cfg(test)]
mod os_denied_tests {
use super::*;
fn code_of(e: &anyhow::Error) -> Option<crate::safety::ErrorCode> {
e.downcast_ref::<crate::safety::SafetyError>()
.map(|s| s.code)
}
#[test]
fn an_os_permission_refusal_is_not_the_shells_policy() {
let e = crate::safety::fs_error(
"file_read",
"x",
&std::io::Error::from(std::io::ErrorKind::PermissionDenied),
);
assert_eq!(code_of(&e), Some(crate::safety::ErrorCode::OsDenied));
// Same exit status as a policy refusal, different code.
assert_eq!(crate::safety::ErrorCode::OsDenied.exit_code(), 77);
assert_ne!(
crate::safety::ErrorCode::OsDenied.as_str(),
crate::safety::ErrorCode::PolicyDeny.as_str()
);
}
#[test]
#[cfg(windows)]
fn a_missing_privilege_is_an_os_refusal() {
// ERROR_PRIVILEGE_NOT_HELD, which fs_symlink meets without Developer Mode.
let e =
crate::safety::fs_error("fs_symlink", "x", &std::io::Error::from_raw_os_error(1314));
assert_eq!(code_of(&e), Some(crate::safety::ErrorCode::OsDenied));
}
#[test]
fn a_plan_over_the_op_limit_is_refused_before_anything_runs() {
let op = |i: usize| {
let mut m = BTreeMap::new();
m.insert("op".to_string(), Value::Str("write".to_string()));
m.insert("path".to_string(), Value::Str(format!("never-written-{i}")));
m.insert("content".to_string(), Value::Str(String::new()));
Value::Record(m)
};
let ops = Value::Array((0..=APPLY_MAX_OPS).map(op).collect());
let e = bi_apply(vec![ops], None).unwrap_err();
assert_eq!(code_of(&e), Some(crate::safety::ErrorCode::BudgetExceeded));
assert!(!std::path::Path::new("never-written-0").exists());
}
}
#[cfg(test)]
mod remote_bind_tests {
use super::*;
#[test]
fn agent_mode_nc_listen_refuses_a_bind_beyond_loopback() {
let _l = crate::safety::ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let prev_mode = std::env::var_os("IRONSHELL_MODE");
let prev_allow = std::env::var_os("IRONSHELL_ALLOW_REMOTE_BIND");
std::env::set_var("IRONSHELL_MODE", "agent");
std::env::remove_var("IRONSHELL_ALLOW_REMOTE_BIND");
// TEST-NET-1 is not assigned here, so even an unguarded bind could only
// fail; the refusal must come before any socket is made.
let mut opts = std::collections::BTreeMap::new();
opts.insert("host".to_string(), Value::Str("192.0.2.1".into()));
let result = bi_nc_listen(vec![Value::Int(9), Value::Record(opts)], None);
match prev_mode {
Some(v) => std::env::set_var("IRONSHELL_MODE", v),
None => std::env::remove_var("IRONSHELL_MODE"),
}
if let Some(v) = prev_allow {
std::env::set_var("IRONSHELL_ALLOW_REMOTE_BIND", v);
}
let err = result.expect_err("agent mode must refuse a remote bind");
assert!(format!("{err}").contains("in agent mode"), "{err}");
}
}
#[cfg(test)]
mod sleep_deadline_tests {
use super::*;
use std::time::{Duration, Instant};
#[test]
fn a_sleep_past_the_deadline_is_cut_short_with_budget_exceeded() {
let _g = crate::safety::enter_deadline(Duration::from_millis(50));
let started = Instant::now();
let err = bi_sleep(vec![Value::Int(5)], None).expect_err("sleep must be refused");
assert!(
started.elapsed() < Duration::from_secs(2),
"sleep ran past the deadline: {:?}",
started.elapsed()
);
assert!(format!("{err}").contains("E_BUDGET_EXCEEDED"), "{err}");
}
#[test]
fn a_short_sleep_inside_the_deadline_returns_null() {
let _g = crate::safety::enter_deadline(Duration::from_secs(10));
let v = bi_sleep(vec![Value::Float(0.01)], None).expect("inside the deadline");
assert!(matches!(v, Value::Null));
}
#[test]
fn without_a_deadline_sleep_returns_null() {
let v = bi_sleep(vec![Value::Float(0.01)], None).expect("no deadline");
assert!(matches!(v, Value::Null));
}
}
#[cfg(test)]
mod win_hwprobe_tests {
use super::*;
use serde_json::json;
fn record(v: Option<Value>) -> BTreeMap<String, Value> {
match v {
Some(Value::Record(m)) => m,
_ => panic!("expected a record"),
}
}
fn str_of(m: &BTreeMap<String, Value>, key: &str) -> Option<String> {
match m.get(key) {
Some(Value::Str(s)) => Some(s.clone()),
_ => None,
}
}
fn int_of(m: &BTreeMap<String, Value>, key: &str) -> Option<i64> {
match m.get(key) {
Some(Value::Int(n)) => Some(*n),
_ => None,
}
}
#[test]
fn gpu_probe_uses_the_driver_size_for_identical_cards_and_amd() {
let probe = json!([
{"Name": "NVIDIA GeForce RTX 3090 Ti", "AdapterRAM": 4293918720_i64, "DriverVersion": "32.0.15.7270", "QwMemorySize": 25757220864_i64},
{"Name": "NVIDIA GeForce RTX 3090 Ti", "AdapterRAM": 4293918720_i64, "DriverVersion": "32.0.15.7270", "QwMemorySize": 25757220864_i64},
{"Name": "AMD Radeon(TM) Graphics", "AdapterRAM": 2147483648_i64, "DriverVersion": "31.0.21923.1000", "QwMemorySize": 2147483648_i64}
]);
let gpus = win_gpus_from_probe(&probe);
assert_eq!(gpus.len(), 3);
assert_eq!(gpus[0].vram_bytes, Some(25_757_220_864));
assert_eq!(gpus[1].vram_bytes, Some(25_757_220_864));
assert_eq!(gpus[1].name.as_deref(), Some("NVIDIA GeForce RTX 3090 Ti"));
assert_eq!(gpus[1].driver.as_deref(), Some("32.0.15.7270"));
assert_eq!(gpus[2].vram_bytes, Some(2_147_483_648));
assert_eq!(gpus[2].name.as_deref(), Some("AMD Radeon(TM) Graphics"));
assert_eq!(gpus[2].driver.as_deref(), Some("31.0.21923.1000"));
}
#[test]
fn gpu_probe_falls_back_to_adapter_ram_without_a_positive_driver_size() {
let probe = json!([
{"Name": "Null size", "AdapterRAM": 1073741824_i64, "DriverVersion": "1.0", "QwMemorySize": null},
{"Name": "Zero size", "AdapterRAM": 536870912_i64, "DriverVersion": "2.0", "QwMemorySize": 0},
{"Name": "No key", "AdapterRAM": 268435456_i64, "DriverVersion": "3.0"}
]);
let gpus = win_gpus_from_probe(&probe);
assert_eq!(gpus.len(), 3);
assert_eq!(gpus[0].vram_bytes, Some(1_073_741_824));
assert_eq!(gpus[1].vram_bytes, Some(536_870_912));
assert_eq!(gpus[2].vram_bytes, Some(268_435_456));
}
#[test]
fn gpu_probe_accepts_a_single_object() {
let probe = json!({"Name": "NVIDIA GeForce RTX 3090 Ti", "AdapterRAM": 4293918720_i64, "DriverVersion": "32.0.15.7270", "QwMemorySize": 25757220864_i64});
let gpus = win_gpus_from_probe(&probe);
assert_eq!(gpus.len(), 1);
assert_eq!(gpus[0].vram_bytes, Some(25_757_220_864));
}
#[test]
fn gpu_probe_skips_non_objects_and_empty_output() {
assert!(win_gpus_from_probe(&json!([])).is_empty());
assert!(win_gpus_from_probe(&json!(null)).is_empty());
assert!(win_gpus_from_probe(&json!("text")).is_empty());
let gpus = win_gpus_from_probe(&json!([1, "x", {"Name": "Kept"}]));
assert_eq!(gpus.len(), 1);
assert_eq!(gpus[0].name.as_deref(), Some("Kept"));
assert_eq!(gpus[0].vram_bytes, None);
assert_eq!(gpus[0].driver, None);
}
#[test]
fn gpu_record_has_the_name_vram_and_driver_shape() {
let g = WinGpu {
name: Some("NVIDIA GeForce RTX 3090 Ti".to_string()),
vram_bytes: Some(25_757_220_864),
driver: Some("32.0.15.7270".to_string()),
};
let m = record(Some(win_gpu_record(&g)));
assert_eq!(m.len(), 3);
assert_eq!(
str_of(&m, "name").as_deref(),
Some("NVIDIA GeForce RTX 3090 Ti")
);
assert_eq!(int_of(&m, "vram"), Some(25_757_220_864));
assert_eq!(str_of(&m, "driver").as_deref(), Some("32.0.15.7270"));
}
#[test]
fn battery_status_codes_map_per_the_documented_table() {
let expected: [(i64, &str); 15] = [
(0, "unknown"),
(1, "discharging"),
(2, "ac"),
(3, "full"),
(4, "low"),
(5, "critical"),
(6, "charging"),
(7, "charging"),
(8, "charging"),
(9, "charging"),
(10, "unknown"),
(11, "partially_charged"),
(12, "unknown"),
(-1, "unknown"),
(99, "unknown"),
];
for (code, want) in expected {
assert_eq!(windows_battery_status(code), want, "code {code}");
}
}
#[test]
fn battery_object_reports_a_ups_on_ac_with_its_name() {
let probe = json!({"Name": "Back-UPS ES 600M1 FW:932.2 USB FW:L2", "EstimatedChargeRemaining": 100, "BatteryStatus": 2});
let m = record(windows_battery_from_probe(&probe));
assert_eq!(int_of(&m, "capacity"), Some(100));
assert_eq!(str_of(&m, "status").as_deref(), Some("ac"));
assert_eq!(
str_of(&m, "name").as_deref(),
Some("Back-UPS ES 600M1 FW:932.2 USB FW:L2")
);
}
#[test]
fn battery_name_is_omitted_when_null_or_blank() {
let null_name = json!({"Name": null, "EstimatedChargeRemaining": 50, "BatteryStatus": 1});
let blank_name = json!({"Name": " ", "EstimatedChargeRemaining": 50, "BatteryStatus": 1});
for probe in [null_name, blank_name] {
let m = record(windows_battery_from_probe(&probe));
assert!(!m.contains_key("name"));
assert_eq!(str_of(&m, "status").as_deref(), Some("discharging"));
}
}
#[test]
fn battery_missing_fields_and_array_form() {
let no_status = json!({"EstimatedChargeRemaining": 40});
let m = record(windows_battery_from_probe(&no_status));
assert_eq!(str_of(&m, "status").as_deref(), Some("unknown"));
assert_eq!(int_of(&m, "capacity"), Some(40));
let no_capacity = json!({"BatteryStatus": 3});
let m = record(windows_battery_from_probe(&no_capacity));
assert!(!m.contains_key("capacity"));
assert_eq!(str_of(&m, "status").as_deref(), Some("full"));
let array =
json!([{"Name": "Battery", "EstimatedChargeRemaining": 87, "BatteryStatus": 6}]);
let m = record(windows_battery_from_probe(&array));
assert_eq!(str_of(&m, "status").as_deref(), Some("charging"));
assert_eq!(str_of(&m, "name").as_deref(), Some("Battery"));
assert!(windows_battery_from_probe(&json!([])).is_none());
assert!(windows_battery_from_probe(&json!(null)).is_none());
}
}
#[cfg(test)]
mod win_loadgpu_tests {
use super::*;
fn code_of(e: &anyhow::Error) -> Option<crate::safety::ErrorCode> {
e.downcast_ref::<crate::safety::SafetyError>()
.map(|s| s.code)
}
fn gpu_fields(output: &str) -> BTreeMap<String, Value> {
match gpu_memory_record(output).expect("valid nvidia-smi rows") {
Value::Record(rec) => rec,
other => panic!("expected a Record, got {other:?}"),
}
}
#[test]
fn load_fields_are_null_without_a_sample_and_floats_with_one() {
assert_eq!(
load_field_values(None),
[Value::Null, Value::Null, Value::Null]
);
assert_eq!(
load_field_values(Some([0.5, 0.25, 0.125])),
[Value::Float(0.5), Value::Float(0.25), Value::Float(0.125)]
);
}
#[test]
fn one_gpu_row_gives_its_figures_and_one_device() {
let rec = gpu_fields("24564, 4919, 19395\n");
assert_eq!(rec.get("total_mb"), Some(&Value::Int(24564)));
assert_eq!(rec.get("used_mb"), Some(&Value::Int(4919)));
assert_eq!(rec.get("free_mb"), Some(&Value::Int(19395)));
assert_eq!(rec.get("devices"), Some(&Value::Int(1)));
}
#[test]
fn two_gpu_rows_are_summed_across_every_gpu() {
// CRLF line endings, as nvidia-smi prints them on Windows.
let rec = gpu_fields("24564, 4919, 19395\r\n24564, 772, 23792\r\n");
assert_eq!(rec.get("total_mb"), Some(&Value::Int(49128)));
assert_eq!(rec.get("used_mb"), Some(&Value::Int(5691)));
assert_eq!(rec.get("free_mb"), Some(&Value::Int(43187)));
assert_eq!(rec.get("devices"), Some(&Value::Int(2)));
}
#[test]
fn a_malformed_row_is_refused_with_bad_state_not_skipped() {
let e = gpu_memory_record("24564, 4919, 19395\n24564, oops, 23792\n")
.expect_err("a non-numeric row must be refused");
assert_eq!(code_of(&e), Some(crate::safety::ErrorCode::BadState));
}
#[test]
fn a_short_or_long_row_is_refused_with_bad_state() {
let short = gpu_memory_record("24564, 4919\n").expect_err("two fields");
assert_eq!(code_of(&short), Some(crate::safety::ErrorCode::BadState));
let long = gpu_memory_record("24564, 4919, 19395, 7\n").expect_err("four fields");
assert_eq!(code_of(&long), Some(crate::safety::ErrorCode::BadState));
}
#[test]
fn no_rows_is_refused_with_bad_state() {
for output in ["", "\n"] {
let e = gpu_memory_record(output).expect_err("no GPU rows");
assert_eq!(code_of(&e), Some(crate::safety::ErrorCode::BadState));
}
}
}
#[cfg(all(test, windows))]
mod win_job_tests {
use super::*;
use std::collections::BTreeSet;
use std::time::{Duration, Instant};
/// The live `ping.exe` processes started with `-n <count>`. Each test uses its
/// own count, so tests that run in parallel do not see each other's processes.
fn pings_with_count(count: &str) -> BTreeSet<u32> {
let mut sys = sysinfo::System::new();
sys.refresh_processes_specifics(
sysinfo::ProcessesToUpdate::All,
true,
sysinfo::ProcessRefreshKind::everything(),
);
sys.processes()
.iter()
.filter(|(_, p)| {
p.name().eq_ignore_ascii_case("ping.exe")
&& p.cmd().iter().any(|a| a.to_str() == Some(count))
})
.map(|(pid, _)| pid.as_u32())
.collect()
}
/// Runs `cmd /C <line>` through `run_tree` on a thread. Before the call can
/// be judged, a `ping -n <count>` grandchild must exist, so a pass cannot come
/// from the grandchild never starting. Returns the run, how long the call took,
/// and any such `ping` still alive a second after the call returned.
fn run_with_ping(
line: &'static str,
count: &'static str,
limit: Duration,
) -> (TreeRun, Duration, BTreeSet<u32>) {
let before = pings_with_count(count);
let call = std::thread::spawn(move || {
let started = Instant::now();
let run = run_tree(
"cmd",
&["/C", line],
|_| {},
None,
limit,
Keep::Tail(4096),
Keep::Tail(4096),
);
(run, started.elapsed())
});
let until = Instant::now() + Duration::from_secs(10);
while pings_with_count(count).is_subset(&before) && Instant::now() < until {
std::thread::sleep(Duration::from_millis(50));
}
assert!(
!pings_with_count(count).is_subset(&before),
"the grandchild ping never started"
);
let (run, elapsed) = call.join().expect("the run thread panicked");
let run = match run {
Ok(run) => run,
Err(_) => panic!("the run did not start"),
};
std::thread::sleep(Duration::from_secs(1));
let survivors = pings_with_count(count)
.difference(&before)
.copied()
.collect();
(run, elapsed, survivors)
}
/// `cmd` exits at once, but its `start /B` child keeps the inherited pipes and
/// would run for 33 s. The job must take it down: the call returns quickly,
/// the pipes close, and the grandchild is gone.
#[test]
fn grandchild_holding_a_pipe_is_killed_after_the_child_exits() {
let (run, elapsed, survivors) = run_with_ping(
"start /B ping -n 33 127.0.0.1",
"33",
Duration::from_secs(20),
);
assert!(
!run.timed_out,
"the child exits at once; the call must not time out"
);
assert!(
!run.pipes_abandoned,
"the grandchild's pipes must close once it is killed"
);
assert!(
elapsed < Duration::from_secs(10),
"the call took {elapsed:?}"
);
assert!(
survivors.is_empty(),
"ping survived the call: {survivors:?}"
);
}
/// The child is still running at the deadline. Its job, and the `ping` it
/// started, must both be killed, and the call reports the timeout.
#[test]
fn deadline_kills_the_child_and_its_descendants() {
// The deadline leaves the watcher time to see the grandchild: on a
// loaded host one process-list refresh can take seconds, and a 4 s
// deadline once killed ping before the watcher saw it start. ping
// still runs about 33 s, so a pass needs the kill.
let (run, elapsed, survivors) =
run_with_ping("ping -n 34 127.0.0.1", "34", Duration::from_secs(8));
assert!(run.timed_out, "the deadline must report a timeout");
assert!(
elapsed < Duration::from_secs(15),
"the call took {elapsed:?}"
);
assert!(
survivors.is_empty(),
"ping survived the deadline kill: {survivors:?}"
);
}
}
#[cfg(test)]
mod ai_local_generate_chat_tests {
use super::*;
fn message(role: &str, content: Value) -> Value {
let mut rec = BTreeMap::new();
rec.insert("role".to_string(), Value::Str(role.to_string()));
rec.insert("content".to_string(), content);
Value::Record(rec)
}
/// The error code of a refused `ai_local_generate` call on a handle that
/// names no loaded model, so only the argument check can refuse it.
fn refused(messages: Vec<Value>) -> crate::safety::ErrorCode {
let args = vec![Value::Str("candle:x".to_string()), Value::Array(messages)];
let e = bi_ai_local_generate(args, None).unwrap_err();
e.downcast_ref::<crate::safety::SafetyError>()
.map(|s| s.code)
.expect("a SafetyError")
}
#[test]
fn malformed_chat_messages_are_bad_arg_before_any_backend() {
use crate::safety::ErrorCode::BadArg;
assert_eq!(refused(vec![]), BadArg);
assert_eq!(refused(vec![Value::Str("hi".to_string())]), BadArg);
assert_eq!(
refused(vec![message("bot", Value::Str("hi".to_string()))]),
BadArg
);
assert_eq!(refused(vec![message("user", Value::Int(3))]), BadArg);
let mut no_content = BTreeMap::new();
no_content.insert("role".to_string(), Value::Str("user".to_string()));
assert_eq!(refused(vec![Value::Record(no_content)]), BadArg);
let mut extra = BTreeMap::new();
extra.insert("role".to_string(), Value::Str("user".to_string()));
extra.insert("content".to_string(), Value::Str("hi".to_string()));
extra.insert("name".to_string(), Value::Str("x".to_string()));
assert_eq!(refused(vec![Value::Record(extra)]), BadArg);
}
}