use ironcrypto::core_types::traits::Digest as _;
use ironcrypto::hash::Sha256;
use lazy_static::lazy_static;
use serde_json::{json, Value as Json};
use std::collections::HashSet;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
const GENESIS_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000";
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Effect {
Pure,
ReadLocal,
WriteLocal,
Destructive,
Process,
Network,
Exec,
Privileged,
}
impl Effect {
pub fn retry_safe_by_class(&self) -> bool {
matches!(self, Effect::Pure | Effect::ReadLocal)
}
pub fn as_str(&self) -> &'static str {
match self {
Effect::Pure => "pure",
Effect::ReadLocal => "read_local",
Effect::WriteLocal => "write_local",
Effect::Destructive => "destructive",
Effect::Process => "process",
Effect::Network => "network",
Effect::Exec => "exec",
Effect::Privileged => "privileged",
}
}
pub fn is_filesystem(&self) -> bool {
matches!(self, Effect::WriteLocal | Effect::Destructive)
}
}
pub fn fips_enabled() -> bool {
std::env::var("IRONSHELL_FIPS")
.map(|v| {
let v = v.trim();
v.eq_ignore_ascii_case("1")
|| v.eq_ignore_ascii_case("on")
|| v.eq_ignore_ascii_case("true")
})
.unwrap_or(false)
}
pub fn require_fips_hash(algo: &str) -> anyhow::Result<()> {
if fips_enabled() && is_weak_hash(algo) {
return Err(anyhow::Error::new(SafetyError {
code: ErrorCode::FipsDisallowed,
message: format!(
"hash algorithm '{algo}' is not FIPS-approved (IRONSHELL_FIPS active)"
),
builtin: String::new(),
hint: "use sha256, sha384 or sha512; MD5 and SHA-1 are refused in FIPS mode"
.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: "a FIPS-approved hash algorithm".to_string(),
got: algo.to_string(),
extra: serde_json::Map::new(),
}));
}
Ok(())
}
pub fn is_weak_hash(algo: &str) -> bool {
matches!(
algo.trim().to_ascii_lowercase().as_str(),
"md5" | "sha1" | "sha-1"
)
}
pub fn idempotent(name: &str) -> bool {
const END_STATE: &[&str] = &[
"write_file",
"write_json",
"text_write",
"save_json",
"fs_write",
"mkdir",
"fs_mkdir",
"create_dir",
"chmod",
"fs_chmod",
"chown",
"fs_chown",
"symlink",
"fs_symlink",
];
const REMOVALS: &[&str] = &[
"rm",
"remove_file",
"fs_remove",
"delete_file",
"rmdir",
"remove_dir",
"kubectl_delete",
"svc_delete",
"role_delete",
"delete_role",
];
if END_STATE.contains(&name) || REMOVALS.contains(&name) {
return true;
}
if name.starts_with("http_get") || name.starts_with("web_get") {
return true;
}
effect_of(name).retry_safe_by_class()
}
pub fn effect_of(name: &str) -> Effect {
if crate::plugins::is_manifest_builtin(name) {
return Effect::Exec;
}
thread_local! {
static CACHE: std::cell::RefCell<rustc_hash::FxHashMap<String, Effect>> =
std::cell::RefCell::new(rustc_hash::FxHashMap::default());
}
if let Some(e) = CACHE.with(|c| c.borrow().get(name).copied()) {
return e;
}
let e = classified_effect(name)
.or_else(|| inherited_effect(name))
.unwrap_or(Effect::Pure);
CACHE.with(|c| {
c.borrow_mut().insert(name.to_string(), e);
});
e
}
fn severity(effect: Effect) -> u8 {
match effect {
Effect::Pure => 0,
Effect::ReadLocal => 1,
Effect::WriteLocal => 2,
Effect::Network => 3,
Effect::Process => 4,
Effect::Destructive => 5,
Effect::Exec => 6,
Effect::Privileged => 7,
}
}
lazy_static! {
static ref INHERITED_EFFECT: std::collections::HashMap<&'static str, Effect> = {
let mut map = std::collections::HashMap::new();
for group in crate::builtins::alias_groups() {
let strictest = group
.iter()
.filter_map(|n| classified_effect(n))
.max_by_key(|e| severity(*e));
let Some(strictest) = strictest else { continue };
for name in group {
if classified_effect(name).is_none() {
map.insert(name, strictest);
}
}
}
map
};
}
fn inherited_effect(name: &str) -> Option<Effect> {
INHERITED_EFFECT.get(name).copied()
}
pub fn effect_is_declared(name: &str) -> bool {
classified_effect(name).is_some() || inherited_effect(name).is_some()
}
fn classified_effect(name: &str) -> Option<Effect> {
match name {
"sd_replace" => Some(Effect::Pure),
"enumerate" => Some(Effect::Pure),
"crypto_base64_decode" | "crypto_base64_encode" => Some(Effect::Pure),
"find"
| "pwd"
| "is_linux"
| "check_permission"
| "has_permission"
| "get_roles"
| "user_roles"
| "list_roles"
| "roles_list"
| "check_compliance"
| "compliance_check"
| "compliance_report"
| "generate_compliance"
| "feature_enabled"
| "has_feature"
| "features"
| "feature_list"
| "marketplace_list"
| "mcp_cache_status"
| "mcp-cache-status"
| "safety_status"
| "governor_status"
| "sess_usage"
| "session_id" => Some(Effect::ReadLocal),
"from-json" | "from_json" | "sha256" | "md5" | "crypto_sign" | "crypto_verify_signature" => {
Some(Effect::ReadLocal)
}
"try_repair"
| "sess_open"
| "sess_close"
| "session_start"
| "mcp_cache_clear"
| "mcp-cache-clear"
| "semantic_cache_get"
| "cache_get" => Some(Effect::WriteLocal),
"rbac_grant" | "rbac_principal" | "rbac_login" | "rbac_register" => {
Some(Effect::Privileged)
}
"rbac_logout" => Some(Effect::WriteLocal),
"rbac_session" | "rbac_can" => Some(Effect::ReadLocal),
"mcp_call" | "mcp_call_tool" => Some(Effect::Exec),
"config_set" => Some(Effect::WriteLocal),
"monitor_tcpdump" => Some(Effect::Privileged),
"user_add" | "user_del" | "user_mod" | "user_passwd" | "user_lock" | "user_unlock"
| "group_add" | "group_del" | "group_mod" | "acl_set" | "fs_mount" | "fs_unmount"
| "svc_create" | "svc_delete" | "sudo_exec" | "pkg_install" | "pkg_remove"
| "pkg_update" | "pkg_upgrade" | "pkg_clean" | "pkg_add_source" | "pkg_hold"
| "pkg_unhold" | "pkg_autoremove" => Some(Effect::Privileged),
"acl_get" => Some(Effect::ReadLocal),
"monitor_perf_stat" | "monitor_perf_record" => Some(Effect::Exec),
"monitor_ethtool" | "monitor_iftop" | "monitor_ip_addr" | "monitor_ip_link"
| "monitor_ip_route" | "monitor_logins" | "monitor_nethogs" | "monitor_sockets"
| "monitor_syslog" | "monitor_users"
| "monitor_alert_history" | "monitor_alert_list" | "monitor_dstat" | "monitor_glances"
| "monitor_health_check" | "monitor_htop" | "monitor_iotop" | "monitor_memory"
| "monitor_nmon" | "monitor_watch_cpu" | "monitor_watch_disk"
| "monitor_watch_memory"
| "monitor_uptime" => Some(Effect::ReadLocal),
"monitor_alert_create" | "monitor_alert_remove" => Some(Effect::WriteLocal),
"remote_exec" | "workspace_sync" => Some(Effect::ReadLocal),
"whoami" => Some(Effect::ReadLocal),
"rm"
| "rmdir"
| "file_delete"
| "file_delete_lines"
| "db_kv_delete"
| "db_sqlite_delete"
| "docker_rm"
| "podman_rm"
| "docker_compose_down"
| "truncate"
| "k8s_delete"
| "platform_db_delete"
| "cloud_instance_destroy"
| "db_sqlite_drop_table"
| "delete_lines"
| "delete_role"
| "kubectl_delete"
| "role_delete"
| "cron_remove" | "at_remove"
| "terraform_destroy"
| "helm_uninstall"
| "marketplace_uninstall" => Some(Effect::Destructive),
"workflow_cancel" | "workflow_pause" | "workflow_resume"
| "workflow_create" | "circuit_breaker_create"
| "proc_kill" | "kill" | "signal" | "pkill" | "sudo_check"
| "proc_suspend" | "proc_resume" | "proc_set_priority"
| "cron_disable"
| "tmux_kill"
| "svc_restart" | "k8s_rollout_restart" => Some(Effect::Process),
| "workflow_execute"
| "workflow_register" | "workflow_pipeline" | "workflow_map_reduce"
| "workflow_saga" | "workflow_fan_out" | "workflow_scatter_gather"
| "cron_enable" | "cron_add" | "at_schedule"
| "sh" | "exec" | "system" | "timeout" | "timeout_cmd" | "xargs" | "xargs_exec"
| "proc_spawn" | "strace" | "strace_cmd" | "ltrace" | "ltrace_cmd"
| "perf_stat" | "perf_record" | "tmux_new" | "tmux_send"
| "docker_exec" | "podman_exec" | "k8s_exec" | "kubectl_exec"
| "ssh_exec"
| "rlm_spawn" | "spawn_agent"
| "tool_exec" | "tool_execute"
| "db_sqlite_exec" | "sqlite_exec"
| "npm_install" | "yarn_install" | "pnpm_install" | "bun_install"
| "pipx_install" | "poetry_install" | "asdf_install"
| "uv_pip"
| "rustup_update"
| "helm_install" | "helm_upgrade" | "marketplace_install" | "pre_commit_install" => Some(Effect::Exec),
"scp_upload" | "scp_download" | "wget_download" | "marketplace_publish" => Some(Effect::Network),
"marketplace_search" | "marketplace_info" | "marketplace_update" => Some(Effect::Network),
"ai_gateway" => Some(Effect::Network),
"ai" | "agent" | "agent_with_mcp" | "swarm" | "rlm_agent" | "ai_backends" | "mcp_client"
| "mcp_detect"
| "mcp_servers" => {
Some(Effect::Network)
}
"ai_detect" | "ai_local_generate" | "ai-backends" => Some(Effect::Network),
"ai_gpu_memory" | "ai_model_fits" | "ai_local_load" => Some(Effect::ReadLocal),
"undo" | "rewind" | "session_undo" | "session_redo" | "journal_clear" | "session_end" => {
Some(Effect::WriteLocal)
}
"vault_models" | "vault_conversions" | "ai_supported_conversions" => Some(Effect::Exec),
"vault_convert" | "ai_convert_model" => Some(Effect::Exec),
| "dd_copy"
| "git_clean"
| "git_reset"
| "rsync_sync"
| "file_move" | "file_rename"
| "session_rollback" => Some(Effect::Destructive),
| "gh_issue"
| "gh_pr"
| "gh_repo_cmd"
| "git_fetch"
| "git_pull"
| "git_push"
| "glab_issue"
| "glab_mr"
| "host_lookup"
| "iperf3_client"
| "iperf3_server"
| "mtr_trace"
| "nmap_quick"
| "nmap_scan"
| "pkg_changelog"
| "skopeo_copy"
| "skopeo_inspect"
| "socat_relay"
| "ssh_copy_id"
| "ssh_tunnel"
| "trivy_image"
| "trivy_scan" => Some(Effect::Network),
| "gui_close_window"
| "gui_focus_window"
| "gui_maximize_window"
| "gui_minimize_window"
| "gui_move_window"
| "gui_resize_window"
| "screen_attach"
| "screen_new"
| "svc_disable"
| "svc_enable"
| "svc_start"
| "svc_stop"
| "tmux_attach" => Some(Effect::Process),
| "add_entity"
| "add_node"
| "add_relation"
| "age_decrypt"
| "age_encrypt"
| "age_keygen"
| "audit_log"
| "black_format"
| "bzip2_compress"
| "bzip2_decompress"
| "cache_ai"
| "chgrp"
| "clipboard_set"
| "clipboard_set_image"
| "cluster_add_node"
| "cluster_create"
| "code_format"
| "config_init"
| "crypto_decrypt"
| "crypto_encrypt"
| "db_sqlite_backup"
| "env_set"
| "feature_set"
| "fs_tempdir"
| "fs_tempfile"
| "gui_screenshot"
| "gui_screenshot_window"
| "gzip_compress"
| "gzip_decompress"
| "index_docs"
| "kg_add"
| "kg_relate"
| "marketplace_rate"
| "mypy_check"
| "platform_db_export"
| "platform_db_import"
| "platform_db_init"
| "platform_db_store"
| "plugin_disable"
| "plugin_enable"
| "plugin_load"
| "plugin_unload"
| "rag_index"
| "refactor_rename_file"
| "ruff_check"
| "ruff_format"
| "sed_replace"
| "semantic_cache"
| "semantic_cache_clear"
| "session_checkpoint"
| "session_restore"
| "set_env"
| "shfmt_format"
| "ssh_keygen"
| "sso_init"
| "tar_create"
| "tar_extract"
| "tee_output"
| "telemetry_reset"
| "uv_venv"
| "xz_compress"
| "xz_decompress"
| "zip_add"
| "zip_create"
| "zip_extract"
| "zoxide_add"
| "zstd_compress"
| "zstd_decompress" => Some(Effect::WriteLocal),
| "act_run"
| "apply"
| "buildah_build"
| "bun_run"
| "cargo_build_cmd"
| "cargo_run"
| "cargo_test_cmd"
| "cmake_build"
| "cmake_configure"
| "deno_run"
| "deno_task"
| "diag_check"
| "diag_errors"
| "diag_fix"
| "diag_lint"
| "diag_warnings"
| "direnv_allow"
| "docker_run"
| "docs_generate"
| "entr_watch"
| "eslint_check"
| "fzf_select"
| "gdb_bt"
| "gdb_run"
| "git_add"
| "git_checkout"
| "git_cherry_pick"
| "git_commit"
| "git_merge"
| "git_rebase"
| "git_stash"
| "git_stash_pop"
| "go_build"
| "go_run"
| "go_test"
| "gui_dialog_file_open"
| "gui_dialog_folder"
| "gui_dialog_input"
| "gui_dialog_message"
| "gui_key_combo"
| "gui_key_press"
| "gui_mouse_click"
| "gui_mouse_drag"
| "gui_mouse_move"
| "gui_mouse_scroll"
| "gui_notify"
| "gui_type_text"
| "hyperfine_bench"
| "input_editor"
| "input_read_password"
| "jq_filter"
| "just_run"
| "lazygit"
| "lazygit_open"
| "lldb_run"
| "lnav_open"
| "logrotate_run"
| "mage_run"
| "make_run"
| "mise_use"
| "multitail_open"
| "ninja_build"
| "node_run"
| "nodemon_run"
| "npm_run"
| "pnpm_run"
| "podman_run"
| "poetry_run"
| "pre_commit_run"
| "prettier_format"
| "pytest_run"
| "refactor_remove_unused"
| "rga_search"
| "task_run"
| "test_bench"
| "test_list"
| "test_run"
| "test_run_file"
| "test_run_function"
| "test_watch"
| "uv_run"
| "valgrind_memcheck"
| "valgrind_run"
| "watchexec_run"
| "yarn_run"
| "yq_query" => Some(Effect::Exec),
| "archive_test"
| "asdf_list"
| "at_list"
| "audit_tail"
| "audit_verify"
| "bat_view"
| "blkid"
| "buildah_images"
| "capabilities"
| "clipboard_get"
| "clipboard_get_image"
| "clipboard_history"
| "clipboard_types"
| "cron_list"
| "crypto_cert_info"
| "crypto_cert_verify"
| "crypto_hash"
| "crypto_hash_file"
| "crypto_hmac"
| "crypto_jwt_decode"
| "crypto_password_hash"
| "crypto_random_bytes"
| "crypto_random_string"
| "crypto_uuid"
| "csv_columns"
| "db_csv_query"
| "db_csv_to_json"
| "db_json_query"
| "db_json_to_csv"
| "db_sqlite_dump"
| "db_sqlite_query"
| "delta_diff"
| "diag_explain"
| "direnv_status"
| "docs_search"
| "env_docker"
| "env_dotnet"
| "env_go"
| "env_java"
| "env_node"
| "env_python"
| "env_ruby"
| "env_rust"
| "eza_list"
| "fd_find"
| "file_diff"
| "fs_df"
| "git_blame"
| "git_branch"
| "git_branches"
| "git_diff"
| "git_diff_staged"
| "git_log"
| "git_remote"
| "git_rev_parse"
| "git_root"
| "git_show"
| "git_stash_list"
| "git_status"
| "git_tags"
| "group_members"
| "gui_color_picker"
| "gui_find_image"
| "gui_get_active_window"
| "gui_list_windows"
| "gui_mouse_position"
| "gui_ocr"
| "gui_screen_size"
| "hadolint_check"
| "hw_audio"
| "hw_battery"
| "hw_gpu"
| "hw_pci"
| "hw_sensors"
| "hw_usb"
| "journalctl"
| "just_list"
| "lsof"
| "mise_list"
| "netstat_info"
| "nm_symbols"
| "objdump_disasm"
| "objdump_headers"
| "open"
| "pgrep"
| "pipx_list"
| "pkg_deps"
| "pkg_files"
| "pkg_info"
| "pkg_list"
| "pkg_owner"
| "pkg_rdeps"
| "pkg_search"
| "pkg_verify"
| "platform_build"
| "platform_capabilities"
| "platform_cpu"
| "platform_cpu_freq"
| "platform_cuda_version"
| "platform_disk_usage"
| "platform_disks"
| "platform_gpu_memory"
| "platform_gpus"
| "platform_has_admin"
| "platform_hostname"
| "platform_kernel"
| "platform_lib_version"
| "platform_libc"
| "platform_libcpp"
| "platform_libs"
| "platform_memory"
| "platform_memory_free"
| "platform_memory_total"
| "platform_network_interfaces"
| "platform_os_version"
| "platform_sdk_version"
| "platform_snapshot"
| "platform_ssl_version"
| "platform_system_libs"
| "project_loc"
| "project_root"
| "project_version"
| "readelf_headers"
| "readelf_sections"
| "rg_search"
| "rustup_show"
| "screen_list"
| "search_by_size"
| "search_by_type"
| "search_code"
| "search_files"
| "search_fixmes"
| "search_modified"
| "search_recent"
| "search_symbols"
| "search_todos"
| "session_diff_since"
| "shellcheck_check"
| "sql_value"
| "startup_list"
| "strings_extract"
| "svc_list"
| "svc_logs"
| "svc_status"
| "tar_list"
| "task_list"
| "tmux_list"
| "tokei_count"
| "top_snapshot"
| "yamllint_check"
| "zip_list"
| "zoxide_query" => Some(Effect::ReadLocal),
| "clipboard_has_text"
| "crypto_checksum"
| "crypto_password_verify"
| "db_kv_get"
| "db_kv_keys"
| "db_sqlite_count"
| "db_sqlite_schema"
| "db_sqlite_tables"
| "db_sqlite_to_json"
| "docker_images"
| "docker_networks"
| "docker_ps"
| "docker_stats"
| "docker_volumes"
| "gpg_list_keys"
| "group_list"
| "hw_cpu"
| "hw_disk"
| "hw_memory"
| "hw_network"
| "openssl_cert_info"
| "platform_build_systems"
| "platform_cloud_clis"
| "platform_compilers"
| "platform_containers"
| "platform_databases"
| "platform_fingerprint"
| "platform_hardware_summary"
| "platform_iac_tools"
| "platform_linters"
| "platform_pkg_lang"
| "platform_runtimes"
| "platform_tool_version"
| "platform_tool_versions"
| "platform_vcs"
| "podman_images"
| "podman_ps"
| "search_grep"
| "search_regex"
| "session_changes"
| "session_checkpoints"
| "svc_info"
| "user_list" => Some(Effect::ReadLocal),
| "clipboard_clear"
| "crypto_generate_key"
| "db_csv_to_sqlite"
| "db_json_to_sqlite"
| "db_kv_set"
| "db_kv_store"
| "db_sqlite_create_table"
| "db_sqlite_import"
| "db_sqlite_insert"
| "db_sqlite_open"
| "db_sqlite_to_csv"
| "db_sqlite_update"
| "db_sqlite_vacuum"
| "gpg_decrypt"
| "gpg_encrypt"
| "openssl_genrsa"
| "syntax_add"
| "syntax_categories"
| "syntax_get"
| "syntax_list"
| "syntax_search" => Some(Effect::WriteLocal),
"helm_repos" => Some(Effect::ReadLocal),
| "helm_list"
| "helm_search"
| "helm_status"
| "k8s_apply"
| "k8s_cluster_info"
| "k8s_configmaps"
| "k8s_context"
| "k8s_contexts"
| "k8s_describe"
| "k8s_events"
| "k8s_get"
| "k8s_ingresses"
| "k8s_logs"
| "k8s_namespaces"
| "k8s_nodes"
| "k8s_pods"
| "k8s_rollout_status"
| "k8s_scale"
| "k8s_secrets"
| "k8s_top_nodes"
| "k8s_top_pods" => Some(Effect::Network),
| "ansible_galaxy"
| "ansible_inventory"
| "ansible_playbook"
| "ansible_vault"
| "diag_all"
| "docker_build"
| "docker_compose_ps"
| "docker_compose_up"
| "docker_cp"
| "docker_inspect"
| "docker_logs"
| "docker_pull"
| "docker_push"
| "docker_stop"
| "docker_tag"
| "docker_top"
| "gui_mouse_double_click"
| "podman_build"
| "podman_logs"
| "podman_pull"
| "podman_stop"
| "terraform_apply"
| "terraform_init"
| "terraform_output"
| "terraform_plan"
| "terraform_state"
| "terraform_validate"
| "terraform_workspace" => Some(Effect::Exec),
"fs_link" | "fs_symlink" | "git_ignore" | "perm_set" => Some(Effect::WriteLocal),
"platform_has_network" | "platform_machine_id" => Some(Effect::ReadLocal),
n if n.starts_with("http")
|| n.starts_with("web_")
|| n.starts_with("net_")
|| n.starts_with("nc_") =>
{
Some(Effect::Network)
}
"repl_serve" | "repl_connect" => Some(Effect::Network),
"k8s_deployments" | "k8s_services" => Some(Effect::Network),
"fs_mounts" => Some(Effect::ReadLocal),
"file_write" | "json_set" | "file_append" | "file_copy" | "mkdir" | "mkdirp" | "file_mkdir" | "touch"
| "file_edit" | "file_insert" | "file_patch" | "session_export"
| "file_backup"
| "file_replace" | "text_replace" | "str_replace_in_file"
| "write_file" | "write_json" | "text_write" | "save_json"
| "gui_dialog_file_save"
| "chmod" | "fs_chmod" | "fs_chown"
| "cd" => Some(Effect::WriteLocal),
"archive_info" | "cat" | "code_comments" |
"code_exports" | "code_imports" | "code_parse" | "code_symbols" |
"code_todos" | "diag_config" | "docs_changelog" | "docs_examples" |
"env" | "env_container" | "env_path" | "env_shell" |
"env_var" | "env_vars" | "env_venv" | "fs_du" |
"fs_glob" | "fs_lstat" | "fs_readlink" | "fs_realpath" |
"fs_stat" | "fs_tree" | "fs_walk" | "fs_watch" | "fs_unwatch" | "grep" |
"head" | "ls" | "make_targets" | "pager" |
"perm_check" | "perm_get" | "pkg_history" | "pkg_sources" |
"platform_db_compare" | "platform_db_list" | "platform_db_load" | "platform_has_gui" | "platform_shell_type" |
"project_dependencies" | "project_dev_dependencies" | "project_gitignore" | "project_languages" |
"project_license" | "project_name" | "project_readme" | "project_scripts" | "project_size" |
"plan_diff" | "project_structure" | "project_test_files" | "read_text"
| "refactor_organize_imports" |
"ssh_config" | "tail" | "wc" | "Get-Content" | "get-content" | "Get-Files"
| "get-files" => Some(Effect::ReadLocal),
n if n.starts_with("file_") || n.starts_with("proc_") || n.starts_with("sys_") => {
Some(Effect::ReadLocal)
}
"role_create" | "create_role" | "role_grant" | "grant_permission" | "role_revoke"
| "revoke_permission" => Some(Effect::Privileged),
"rag_clear" => Some(Effect::WriteLocal),
"tx_begin" | "tx_commit" | "tx_rollback" | "tx_rollback_to" | "tx_savepoint" => {
Some(Effect::WriteLocal)
}
"exit" => Some(Effect::Process),
"sess_eval" => Some(Effect::Exec),
"env_unset" => Some(Effect::WriteLocal),
"feature_enable" | "feature_disable" => Some(Effect::WriteLocal),
"handle_drop" => Some(Effect::WriteLocal),
"audit_export" | "export_audit" => Some(Effect::ReadLocal),
"cloud_config" | "sso_logout" | "logout_sso" | "cluster_remove_node"
| "workspace_create" | "workspace_join" | "workspace_leave"
| "workspace_share_agent" | "telemetry_enable" | "telemetry_disable"
| "repl_disconnect" | "ai_add_route" | "ai-add-route" | "ai_set_default"
| "ai-set-default" | "ai_set_load_balancing" | "ai-set-load-balancing"
| "ai_reset_usage" | "ai-reset-usage" | "ai_local_unload" | "ai-local-unload" => {
Some(Effect::WriteLocal)
}
"sso_validate" | "sso_status" | "sso_info" | "cluster_nodes" | "cluster_status"
| "workspace_members" | "workspace_agents" | "workspace_list" | "telemetry_status"
| "telemetry_report" | "telemetry_events" | "repl_sessions" | "rag_search"
| "rag_query" | "ai_cost" | "ai-cost" | "ai_usage" | "ai-usage"
| "ai_load_balancing" | "ai-load-balancing" | "ai_local_backends"
| "ai-local-backends" | "ai_local_embed" | "ai-local-embed" | "ai_providers"
| "ai-providers" | "ai_registry_stats" | "ai-registry-stats" | "ai_routes"
| "ai-routes" => Some(Effect::ReadLocal),
"code_dependencies" | "code_outline" | "docs_readme" | "config" | "config_get"
| "config_path" | "config_reload" | "project_config_files" | "project_entry_points"
| "project_type" | "plugin_list" | "plugin_info" | "lsblk" | "mount_info"
| "platform_cpu_count" | "platform_detect_tools" | "platform_has_container"
| "platform_has_sudo" | "platform_has_tool" | "platform_pkg_managers"
| "session_history" | "journal" | "query_audit" | "audit_query" | "audit_statistics"
| "audit_stats" | "tx_status" | "handle" | "handles" | "record_summary" => {
Some(Effect::ReadLocal)
}
"platform_check_reqs" | "platform_require" | "platform_satisfies" => Some(Effect::Exec),
"a2a_send" | "a2a_receive" | "a2a_broadcast" | "a2a_register" | "a2a_unregister"
| "nanda_propose" | "nanda_vote" | "nanda_commit" | "nanda_abort" | "job_cancel"
| "finetune_cancel" | "cancel_finetune" => Some(Effect::WriteLocal),
"a2a_discover" | "a2a_status" | "a2a_agents" | "nanda_status" | "nanda_consensus"
| "nanda_quorum" | "job_status" | "job_list" | "jobs" | "job_results" | "results"
| "finetune_status" | "finetune_progress" | "finetune_list" | "list_finetunes"
| "kg_query" | "query_kg" | "kg_entities" | "entities" | "kg_relations" | "relations"
| "rlm_stats" => {
Some(Effect::ReadLocal)
}
"approve"
| "governor_reset"
| "a2ui_notify"
| "a2ui_prompt"
| "a2ui_confirm"
| "a2ui_select"
| "a2ui_progress"
| "a2ui_progress_complete"
| "a2ui_render"
| "a2ui_render_table"
| "a2ui_render_code"
| "a2ui_toast"
| "a2ui_status"
| "a2ui_clear"
| "a2ui_agent_started"
| "a2ui_agent_completed"
| "a2ui_agent_thinking"
| "notify_user"
| "prompt_user"
| "confirm_user"
| "select_user"
| "progress_update"
| "progress_done"
| "render_content"
| "render_table"
| "render_code"
| "toast"
| "status_bar"
| "ui_clear"
| "agent_started"
| "agent_completed"
| "agent_thinking" => Some(Effect::WriteLocal),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Human,
Agent,
}
pub fn truthy_env(name: &str) -> bool {
let raw = std::env::var_os(name);
let Some(v) = raw.as_deref().and_then(|v| v.to_str()) else {
return false;
};
let v = v.trim();
["1", "true", "yes", "on"]
.iter()
.any(|on| v.eq_ignore_ascii_case(on))
}
#[cfg(test)]
lazy_static! {
pub(crate) static ref ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
}
pub fn refuse_if_headless(builtin: &str) -> anyhow::Result<()> {
if current_mode() == Mode::Agent {
return Err(no_ui(builtin));
}
Ok(())
}
pub fn refuse_remote_bind(builtin: &str, ip: std::net::IpAddr) -> anyhow::Result<()> {
if ip.is_loopback() || current_mode() != Mode::Agent {
return Ok(());
}
if matches!(
std::env::var("IRONSHELL_ALLOW_REMOTE_BIND").as_deref(),
Ok("1") | Ok("true")
) {
return Ok(());
}
Err(policy_deny(
builtin,
format!(
"{builtin}: refusing to listen on {ip} in agent mode; a listener beyond loopback accepts peers from the network"
),
"bind 127.0.0.1, or set IRONSHELL_ALLOW_REMOTE_BIND=true if this exposure is intended",
))
}
pub fn current_mode() -> Mode {
if std::env::var_os("IRONSHELL_MODE")
.as_deref()
.and_then(|v| v.to_str())
.is_some_and(|v| v.trim().eq_ignore_ascii_case("agent"))
|| truthy_env("IRONSHELL_AGENT")
{
Mode::Agent
} else {
Mode::Human
}
}
fn policy_permissive() -> bool {
std::env::var("IRONSHELL_POLICY").is_ok_and(|v| v.trim().eq_ignore_ascii_case("permissive"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
Allow,
Deny,
Approve,
}
pub fn decide(effect: Effect, mode: Mode) -> Decision {
if policy_permissive() {
return Decision::Allow;
}
match mode {
Mode::Human => Decision::Allow,
Mode::Agent => match effect {
Effect::Pure | Effect::ReadLocal | Effect::WriteLocal | Effect::Network => {
Decision::Allow
}
Effect::Process | Effect::Destructive | Effect::Exec => Decision::Approve,
Effect::Privileged => Decision::Deny,
},
}
}
pub fn effective_decision(effect: Effect, mode: Mode) -> Decision {
let decision = decide(effect, mode);
if decision == Decision::Allow
&& effect == Effect::Network
&& mode == Mode::Agent
&& !policy_permissive()
&& net_allowlist().is_none()
{
return Decision::Approve;
}
decision
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorCode {
PolicyDeny,
NeedsApproval,
OutsideWorkspace,
BadArg,
BadValue,
Overflow,
User,
Assert,
BudgetExceeded,
UnknownBuiltin,
UnknownField,
UnknownName,
ToolMissing,
NoUi,
Unimplemented,
BadState,
NotFound,
ToolFailed,
Io,
OsDenied,
DecryptFailed,
FipsDisallowed,
CryptoFailed,
Network,
Unknown,
}
impl ErrorCode {
pub fn as_str(&self) -> &'static str {
match self {
ErrorCode::PolicyDeny => "E_POLICY_DENY",
ErrorCode::NeedsApproval => "E_NEEDS_APPROVAL",
ErrorCode::OutsideWorkspace => "E_OUTSIDE_WORKSPACE",
ErrorCode::BadArg | ErrorCode::BadValue => "E_BAD_ARG",
ErrorCode::Overflow => "E_BAD_VALUE",
ErrorCode::User => "E_USER",
ErrorCode::Assert => "E_ASSERT",
ErrorCode::BudgetExceeded => "E_BUDGET_EXCEEDED",
ErrorCode::UnknownBuiltin => "E_UNKNOWN_BUILTIN",
ErrorCode::UnknownField => "E_UNKNOWN_FIELD",
ErrorCode::UnknownName => "E_UNKNOWN_NAME",
ErrorCode::ToolMissing => "E_TOOL_MISSING",
ErrorCode::NoUi => "E_NO_UI",
ErrorCode::Unimplemented => "E_UNIMPLEMENTED",
ErrorCode::BadState => "E_BAD_STATE",
ErrorCode::NotFound => "E_NOT_FOUND",
ErrorCode::ToolFailed => "E_TOOL_FAILED",
ErrorCode::Io => "E_IO",
ErrorCode::OsDenied => "E_OS_DENIED",
ErrorCode::DecryptFailed => "E_DECRYPT_FAILED",
ErrorCode::FipsDisallowed => "E_FIPS_DISALLOWED",
ErrorCode::CryptoFailed => "E_CRYPTO_FAILED",
ErrorCode::Network => "E_NETWORK",
ErrorCode::Unknown => "E_UNKNOWN",
}
}
pub fn retryable(&self) -> bool {
match self {
ErrorCode::BadArg | ErrorCode::UnknownField => true,
ErrorCode::UnknownName => true,
ErrorCode::NotFound => true,
ErrorCode::BadState => true,
ErrorCode::NeedsApproval => true,
ErrorCode::BadValue => false,
ErrorCode::Overflow => false,
ErrorCode::UnknownBuiltin => true,
ErrorCode::OutsideWorkspace => false,
ErrorCode::ToolMissing => false,
ErrorCode::NoUi => false,
ErrorCode::Unimplemented => false,
ErrorCode::ToolFailed => false,
ErrorCode::Io => false,
ErrorCode::OsDenied => false,
ErrorCode::DecryptFailed => false,
ErrorCode::FipsDisallowed => false,
ErrorCode::CryptoFailed => false,
ErrorCode::Network => true,
ErrorCode::PolicyDeny | ErrorCode::BudgetExceeded | ErrorCode::Unknown => false,
ErrorCode::User | ErrorCode::Assert => false,
}
}
pub fn exit_code(&self) -> i32 {
match self {
ErrorCode::UnknownBuiltin | ErrorCode::UnknownName | ErrorCode::ToolMissing => 127,
ErrorCode::BadArg
| ErrorCode::BadValue
| ErrorCode::Overflow
| ErrorCode::UnknownField => 64,
ErrorCode::PolicyDeny | ErrorCode::OutsideWorkspace => 77,
ErrorCode::NeedsApproval => 75,
ErrorCode::BudgetExceeded | ErrorCode::NoUi | ErrorCode::ToolFailed => 69,
ErrorCode::Network => 69,
ErrorCode::Unimplemented => 70,
ErrorCode::NotFound => 66,
ErrorCode::BadState => 76,
ErrorCode::Io => 74,
ErrorCode::OsDenied => 77,
ErrorCode::DecryptFailed => 65,
ErrorCode::FipsDisallowed => 77,
ErrorCode::CryptoFailed => 70,
ErrorCode::User | ErrorCode::Assert => 1,
ErrorCode::Unknown => 1,
}
}
pub fn exit_code_for_wire(code: &str) -> i32 {
const ALL: &[ErrorCode] = &[
ErrorCode::PolicyDeny,
ErrorCode::NeedsApproval,
ErrorCode::OutsideWorkspace,
ErrorCode::BadArg,
ErrorCode::Overflow,
ErrorCode::User,
ErrorCode::Assert,
ErrorCode::BudgetExceeded,
ErrorCode::UnknownBuiltin,
ErrorCode::UnknownField,
ErrorCode::UnknownName,
ErrorCode::ToolMissing,
ErrorCode::NoUi,
ErrorCode::Unimplemented,
ErrorCode::BadState,
ErrorCode::NotFound,
ErrorCode::ToolFailed,
ErrorCode::Io,
ErrorCode::OsDenied,
ErrorCode::DecryptFailed,
ErrorCode::FipsDisallowed,
ErrorCode::CryptoFailed,
ErrorCode::Network,
ErrorCode::Unknown,
];
if code == "E_PARSE" {
return 2;
}
ALL.iter()
.find(|c| c.as_str() == code)
.map_or(1, |c| c.exit_code())
}
}
pub fn no_ui(builtin: &str) -> anyhow::Error {
anyhow::Error::new(SafetyError {
code: ErrorCode::NoUi,
message: format!("{builtin}: no UI is attached to answer this prompt"),
builtin: builtin.to_string(),
hint: "attach a UI (`ish tui`) or avoid interactive builtins in agent mode".to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: "a UI listening for prompts".to_string(),
got: "nothing attached".to_string(),
extra: serde_json::Map::new(),
})
}
pub fn tool_missing(builtin: &str, tool: &str, cause: &str) -> anyhow::Error {
anyhow::Error::new(SafetyError {
code: ErrorCode::ToolMissing,
message: format!("{builtin}: `{tool}` is not installed ({cause})"),
builtin: builtin.to_string(),
hint: format!("install `{tool}` and run this again; no change to the call will help"),
approval: None,
did_you_mean: Vec::new(),
expected: format!("`{tool}` on PATH"),
got: "not installed".to_string(),
extra: serde_json::Map::new(),
})
}
pub fn unimplemented(builtin: &str, what: &str, instead: &str) -> anyhow::Error {
anyhow::Error::new(SafetyError {
code: ErrorCode::Unimplemented,
message: format!("{builtin}: {what}"),
builtin: builtin.to_string(),
hint: format!("{instead}; no change to this call will make it work"),
approval: None,
did_you_mean: Vec::new(),
expected: "an operation IronShell implements".to_string(),
got: "one it does not".to_string(),
extra: serde_json::Map::new(),
})
}
pub fn tool_failed(
builtin: &str,
command: &str,
status: Option<i32>,
stderr: &str,
) -> anyhow::Error {
let status_text = match status {
Some(c) => format!("exited {c}"),
None => "was killed by a signal".to_string(),
};
tool_failed_text(builtin, command, status_text, stderr, None)
}
pub fn tool_failed_hinted(
builtin: &str,
command: &str,
status: Option<i32>,
stderr: &str,
hint: &str,
) -> anyhow::Error {
let status_text = match status {
Some(c) => format!("exited {c}"),
None => "was killed by a signal".to_string(),
};
tool_failed_text(builtin, command, status_text, stderr, Some(hint))
}
pub fn tool_failed_code(builtin: &str, command: &str, code: u32, stderr: &str) -> anyhow::Error {
tool_failed_text(
builtin,
command,
format!("exited 0x{code:08X}"),
stderr,
None,
)
}
fn tool_failed_default_hint(command: &str, status_text: &str) -> String {
const SERVICE_CLIENTS: &[&str] = &[
"docker",
"podman",
"kubectl",
"helm",
"systemctl",
"launchctl",
"service",
"sc",
"nerdctl",
"colima",
];
let program = command
.split_whitespace()
.next()
.unwrap_or(command)
.rsplit(['/', '\\'])
.next()
.unwrap_or(command)
.trim_end_matches(".exe")
.to_ascii_lowercase();
if status_text == "was killed by a signal" {
return format!(
"`{command}` was killed by a signal before it finished; run it by hand to see why"
);
}
let code = status_text
.strip_prefix("exited ")
.and_then(|c| c.parse::<i64>().ok());
if let Some(c) = code {
if !(0..=255).contains(&c) {
return format!("the program crashed (status {c}); run `{command}` by hand to see why");
}
}
if SERVICE_CLIENTS.contains(&program.as_str()) {
format!(
"`{command}` is installed but rejected this work; check that its \
service is running and that the arguments are valid for this host"
)
} else {
format!(
"`{command}` is installed but rejected this work; check that the \
arguments are valid for this host"
)
}
}
fn tool_failed_text(
builtin: &str,
command: &str,
status_text: String,
stderr: &str,
hint: Option<&str>,
) -> anyhow::Error {
let stderr = stderr.trim();
let said = if stderr.is_empty() {
"and printed nothing to stderr".to_string()
} else {
format!("saying: {stderr}")
};
anyhow::Error::new(SafetyError {
code: ErrorCode::ToolFailed,
message: format!("{builtin}: `{command}` {status_text} {said}"),
builtin: builtin.to_string(),
hint: match hint {
Some(hint) => hint.to_string(),
None => tool_failed_default_hint(command, &status_text),
},
approval: None,
did_you_mean: Vec::new(),
expected: format!("`{command}` to exit 0"),
got: status_text,
extra: serde_json::Map::new(),
})
}
pub fn bad_state(builtin: &str, state: &str, needs: &str) -> anyhow::Error {
anyhow::Error::new(SafetyError {
code: ErrorCode::BadState,
message: format!("{builtin}: {state}"),
builtin: builtin.to_string(),
hint: format!("call `{needs}` first, then retry this unchanged"),
approval: None,
did_you_mean: Vec::new(),
expected: format!("the state left by `{needs}`"),
got: state.to_string(),
extra: serde_json::Map::new(),
})
}
pub fn bad_state_with_hint(builtin: &str, state: &str, hint: &str) -> anyhow::Error {
anyhow::Error::new(SafetyError {
code: ErrorCode::BadState,
message: format!("{builtin}: {state}"),
builtin: builtin.to_string(),
hint: hint.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: "the precondition named in the message".to_string(),
got: state.to_string(),
extra: serde_json::Map::new(),
})
}
pub fn io_fault(builtin: &str, what: &str, hint: &str) -> anyhow::Error {
anyhow::Error::new(SafetyError {
code: ErrorCode::Io,
message: format!("{builtin}: {what}"),
builtin: builtin.to_string(),
hint: hint.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: "a working environment".to_string(),
got: what.to_string(),
extra: serde_json::Map::new(),
})
}
pub fn lock_poisoned(builtin: &str, what: &str) -> anyhow::Error {
anyhow::Error::new(SafetyError {
code: ErrorCode::BadState,
message: format!(
"{builtin}: {what} is unusable: an earlier call panicked while holding its lock"
),
builtin: builtin.to_string(),
hint:
"restart the shell; this process's copy of that state cannot be trusted after the panic"
.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: "a lock no earlier call left poisoned".to_string(),
got: "a poisoned lock".to_string(),
extra: serde_json::Map::new(),
})
}
pub fn control_char_path_hint(path: &str) -> Option<&'static str> {
path.chars().any(|c| matches!(c, '\t' | '\n' | '\r')).then_some(
"the path contains a tab, newline or carriage return: in a double-quoted string a backslash starts an escape (\\t, \\n, \\r); write forward slashes (C:/temp/new) or double the backslashes",
)
}
pub fn not_found(builtin: &str, kind: &str, name: &str) -> anyhow::Error {
let is_path = matches!(kind, "source" | "destination" | "path");
let hint = if is_path || matches!(kind, "file" | "directory" | "archive") {
let here = std::env::current_dir()
.map(|d| d.display().to_string())
.unwrap_or_else(|_| "the current directory".to_string());
control_char_path_hint(name)
.map(str::to_string)
.unwrap_or_else(|| {
let what = if is_path { "file or directory" } else { kind };
format!(
"no {what} at this path; a relative path is resolved against {here}, so \
check the spelling and that directory, or list it with ls() first"
)
})
} else {
format!(
"list the available {kind}{} and use an identifier from it",
if kind.ends_with('s') || kind.ends_with("sh") || kind.ends_with("ch") {
"es"
} else {
"s"
}
)
};
anyhow::Error::new(SafetyError {
code: ErrorCode::NotFound,
message: if is_path {
format!("{builtin}: no file or directory at `{name}`")
} else {
format!("{builtin}: no {kind} named '{name}'")
},
builtin: builtin.to_string(),
hint,
approval: None,
did_you_mean: Vec::new(),
expected: if is_path {
"an existing file or directory".to_string()
} else {
format!("an existing {kind}")
},
got: name.to_string(),
extra: serde_json::Map::new(),
})
}
pub fn journal_step_not_found(builtin: &str, id: usize) -> anyhow::Error {
anyhow::Error::new(SafetyError {
code: ErrorCode::NotFound,
message: format!("{builtin}: no journal step with id {id}"),
builtin: builtin.to_string(),
hint: "list the available journal steps with journal() and use an id from them".to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: "an existing journal step".to_string(),
got: id.to_string(),
extra: serde_json::Map::new(),
})
}
pub fn no_file_at(builtin: &str, path: &str) -> anyhow::Error {
if let Some(parent) = missing_parent_of(path) {
return parent_missing(builtin, path, &parent);
}
let here = std::env::current_dir()
.map(|d| d.display().to_string())
.unwrap_or_else(|_| "the current directory".to_string());
anyhow::Error::new(SafetyError {
code: ErrorCode::NotFound,
message: format!("{builtin}: no file at {path}"),
builtin: builtin.to_string(),
hint: control_char_path_hint(path)
.map(str::to_string)
.unwrap_or_else(|| {
format!(
"no file at this path; a relative path is resolved against {here}, so check \
the spelling and that directory, or create the file with file.write first"
)
}),
approval: None,
did_you_mean: Vec::new(),
expected: "an existing file".to_string(),
got: path.to_string(),
extra: serde_json::Map::new(),
})
}
pub fn missing_parent_of(path: &str) -> Option<String> {
if names_a_remote_share(path) {
return None;
}
let parent = std::path::Path::new(path).parent()?;
if parent.as_os_str().is_empty() {
return None;
}
match std::fs::metadata(parent) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Some(strip_verbatim_prefix(parent).display().to_string())
}
_ => None,
}
}
pub fn parent_missing(builtin: &str, path: &str, parent: &str) -> anyhow::Error {
anyhow::Error::new(SafetyError {
code: ErrorCode::NotFound,
message: format!(
"{builtin}: the folder '{parent}' does not exist; create it with mkdir(\"{parent}\")"
),
builtin: builtin.to_string(),
hint: format!(
"mkdir creates the folder and mkdirp also creates missing parents; then run the call again for '{path}'"
),
approval: None,
did_you_mean: Vec::new(),
expected: "an existing directory".to_string(),
got: parent.to_string(),
extra: serde_json::Map::new(),
})
}
const CONFINING_ENV: &[&str] = &[
"IRONSHELL_MODE",
"IRONSHELL_AGENT",
"IRONSHELL_POLICY",
"IRONSHELL_WORKSPACE",
PINNED_ROOT_ENV,
"IRONSHELL_MAX_NET",
"IRONSHELL_MAX_FILES",
"IRONSHELL_MAX_OPS",
"IRONSHELL_MAX_PROCS",
"IRONSHELL_TIMEOUT_MS",
"IRONSHELL_TOKEN_BUDGET",
"IRONSHELL_NET_ALLOW",
"IRONSHELL_FIPS",
"IRONSHELL_DETERMINISTIC",
"IRONSHELL_RBAC_CONFIG",
"IRONSHELL_PRINCIPAL",
"IRONSHELL_AUDIT_LOG",
"IRONSHELL_AUDIT_REQUIRED",
"IRONSHELL_REFUSE_LINKED_READS",
"IRONSHELL_SCRIPT_DEPTH",
];
pub(crate) fn strip_ironshell_env(cmd: &mut std::process::Command) {
let explicit: Vec<std::ffi::OsString> = cmd
.get_envs()
.filter(|(_, value)| value.is_some())
.map(|(name, _)| name.to_os_string())
.collect();
let agent = current_mode() == Mode::Agent;
let keep = if agent { child_env_keep() } else { Vec::new() };
for (name, _) in std::env::vars_os() {
let shell_var = name
.to_str()
.is_some_and(|n| n.to_ascii_uppercase().starts_with("IRONSHELL_"));
let confining = CONFINING_ENV.iter().any(|c| name.eq_ignore_ascii_case(c));
let credential = agent
&& is_secret_name(&name.to_string_lossy())
&& !keep
.iter()
.any(|k| k.eq_ignore_ascii_case(&name.to_string_lossy()));
let named = explicit.iter().any(|e| e.eq_ignore_ascii_case(&name));
if ((shell_var && !confining) || credential) && !named {
cmd.env_remove(&name);
}
}
if agent {
cmd.env("IRONSHELL_MODE", "agent");
}
}
pub fn child_command(program: impl AsRef<std::ffi::OsStr>) -> std::process::Command {
let mut cmd = std::process::Command::new(program);
strip_ironshell_env(&mut cmd);
cmd
}
pub const CHILD_ENV_KEEP: &str = "IRONSHELL_CHILD_ENV_KEEP";
fn child_env_keep() -> Vec<String> {
std::env::var(CHILD_ENV_KEEP)
.unwrap_or_default()
.split(',')
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_string)
.collect()
}
pub fn output_with_timeout(
cmd: std::process::Command,
secs: u64,
) -> std::io::Result<std::process::Output> {
use crate::builtins::{Keep, TreeError};
const OUTPUT_LIMIT: usize = 64 * 1024 * 1024;
let program = cmd.get_program().to_string_lossy().into_owned();
let run = crate::builtins::run_tree_cmd(
cmd,
None,
std::time::Duration::from_secs(secs),
Keep::Head(OUTPUT_LIMIT),
Keep::Head(OUTPUT_LIMIT),
)
.map_err(|e| match e {
TreeError::Spawn(e) | TreeError::Wait(e) => e,
})?;
let status = match run.status {
Some(status) if !run.timed_out => status,
_ => {
return Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!("no response after {secs}s"),
))
}
};
if run.stdout_truncated || run.stderr_truncated {
return Err(std::io::Error::other(budget_error(
&program,
format!("printed more than {OUTPUT_LIMIT} 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,
})
}
pub fn io_budget_error(e: &std::io::Error) -> Option<anyhow::Error> {
let se = e
.get_ref()
.and_then(|inner| inner.downcast_ref::<SafetyError>())?;
Some(anyhow::Error::new(se.clone()))
}
pub fn spawn_error(builtin: &str, tool: &str, e: &std::io::Error) -> anyhow::Error {
if let Some(coded) = io_budget_error(e) {
return coded;
}
if e.kind() == std::io::ErrorKind::TimedOut {
return budget_exceeded(
builtin,
format!("`{tool}` did not finish in time and was stopped: {e}"),
"a stopped tool can leave work half done; check the state before retrying",
);
}
if e.kind() == std::io::ErrorKind::NotFound {
return tool_missing(builtin, tool, &e.to_string());
}
#[cfg(unix)]
if e.kind() == std::io::ErrorKind::PermissionDenied
&& !tool.contains('/')
&& !std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default())
.any(|dir| dir.join(tool).is_file())
{
return tool_missing(
builtin,
tool,
&format!("{e}, from a PATH directory that could not be searched"),
);
}
anyhow::Error::new(SafetyError {
code: ErrorCode::ToolFailed,
message: format!("{builtin}: could not start `{tool}`: {e}"),
builtin: builtin.to_string(),
hint: format!(
"`{tool}` exists but could not be run; check its permissions and \
that it is executable on this host"
),
approval: None,
did_you_mean: Vec::new(),
expected: format!("`{tool}` to start"),
got: e.kind().to_string(),
extra: serde_json::Map::new(),
})
}
pub fn decrypt_failed(
builtin: &str,
message: impl Into<String>,
hint: impl Into<String>,
) -> anyhow::Error {
anyhow::Error::new(SafetyError {
code: ErrorCode::DecryptFailed,
message: message.into(),
builtin: builtin.to_string(),
hint: hint.into(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
})
}
pub fn ic_refusal(builtin: &str, e: ironcrypto::core_types::Error) -> SafetyError {
SafetyError {
code: ErrorCode::CryptoFailed,
message: format!("IronCrypto refused the operation ({e})"),
builtin: builtin.to_string(),
hint: "nothing was computed; do not retry this call unchanged, because the cryptographic module refused it".to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
}
}
pub fn ic_failed(builtin: &str, e: ironcrypto::core_types::Error) -> anyhow::Error {
anyhow::Error::new(ic_refusal(builtin, e))
}
pub fn ic_fill_random(builtin: &str, out: &mut [u8]) -> anyhow::Result<()> {
let mut rng = ironcrypto::drbg::Rng::from_os().map_err(|e| ic_failed(builtin, e))?;
rng.fill(out).map_err(|e| ic_failed(builtin, e))
}
pub fn crypto_failed(
builtin: &str,
message: impl Into<String>,
hint: impl Into<String>,
) -> anyhow::Error {
anyhow::Error::new(SafetyError {
code: ErrorCode::CryptoFailed,
message: message.into(),
builtin: builtin.to_string(),
hint: hint.into(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
})
}
pub fn outside_workspace(builtin: &str, path: &str) -> anyhow::Error {
outside_workspace_shown(builtin, path, path)
}
pub fn outside_workspace_shown(builtin: &str, shown: &str, raw: &str) -> anyhow::Error {
anyhow::Error::new(SafetyError {
code: ErrorCode::OutsideWorkspace,
message: format!("{builtin}: `{shown}` is outside the workspace"),
builtin: builtin.to_string(),
hint: control_char_path_hint(raw)
.map(str::to_string)
.unwrap_or_else(outside_workspace_hint),
approval: None,
did_you_mean: Vec::new(),
expected: "a path inside the workspace".to_string(),
got: shown.to_string(),
extra: serde_json::Map::new(),
})
}
pub fn outside_workspace_hint() -> String {
format!(
"use a path inside the workspace root {}; only the operator can widen the workspace",
workspace_root().display()
)
}
pub fn fs_error(
builtin: &str,
path: impl AsRef<std::path::Path>,
e: &std::io::Error,
) -> anyhow::Error {
fs_error_as(builtin, path, e, false)
}
pub fn fs_write_error(
builtin: &str,
path: impl AsRef<std::path::Path>,
e: &std::io::Error,
) -> anyhow::Error {
fs_error_as(builtin, path, e, true)
}
pub fn dir_not_empty(e: &std::io::Error) -> bool {
if e.kind() == std::io::ErrorKind::DirectoryNotEmpty {
return true;
}
let code = e.raw_os_error();
if cfg!(windows) {
code == Some(145)
} else if cfg!(target_os = "linux") {
code == Some(39)
} else if cfg!(unix) {
code == Some(66)
} else {
false
}
}
fn fs_error_as(
builtin: &str,
path: impl AsRef<std::path::Path>,
e: &std::io::Error,
writing: bool,
) -> anyhow::Error {
let (verb, expected_access) = if writing {
("write", "writable")
} else {
("read", "readable")
};
let path = strip_verbatim_prefix(path.as_ref()).display().to_string();
let path = path.as_str();
let shown = if builtin == "file_mkdir" {
"mkdir"
} else {
builtin
};
match e.kind() {
std::io::ErrorKind::NotFound => match writing.then(|| missing_parent_of(path)).flatten() {
Some(parent) => parent_missing(builtin, path, &parent),
None => not_found(builtin, "file", path),
},
std::io::ErrorKind::PermissionDenied
if !names_a_remote_share(path) && std::path::Path::new(path).is_dir() =>
{
bad_value_in(
builtin,
format!("{builtin}: `{path}` is a directory, not a file"),
&format!(
"pass a file; to act on a directory's files, name them with a glob such as '{path}/**/*'"
),
)
}
std::io::ErrorKind::PermissionDenied => os_denied(
builtin,
format!("{builtin}: the operating system denied access to `{path}`"),
"check the file's permissions and owner; the shell did not refuse this",
),
_ if dir_not_empty(e) => bad_state_with_hint(
shown,
&format!("`{path}` is not empty"),
&if builtin == "rmdir" {
format!("rmdir(\"{path}\", true) removes it and its contents")
} else {
"a directory is removed only when it is empty; empty it first".to_string()
},
),
_ if e.kind() == std::io::ErrorKind::AlreadyExists
|| (cfg!(windows) && e.raw_os_error() == Some(183)) =>
{
if std::path::Path::new(path).is_file() {
bad_state_with_hint(
shown,
&format!("`{path}` exists and is a file"),
"choose another path, or remove that file first",
)
} else {
bad_state_with_hint(
shown,
&format!("`{path}` already exists"),
"choose another path; the existing one is left as it is",
)
}
}
_ if cfg!(windows) && e.raw_os_error() == Some(1314) => os_denied(
builtin,
format!("{builtin}: the operating system denied `{path}`: a required privilege is not held ({e})"),
"enable Developer Mode, or run with the symbolic-link privilege; the shell did not refuse this",
),
_ => anyhow::Error::new(SafetyError {
code: ErrorCode::Io,
message: format!("{builtin}: cannot {verb} `{path}`: {e}"),
builtin: builtin.to_string(),
hint: control_char_path_hint(path)
.map(str::to_string)
.unwrap_or_else(|| {
"a filesystem failure that is neither absence nor a \
permission refusal; the message names the condition"
.to_string()
}),
approval: None,
did_you_mean: Vec::new(),
expected: format!("`{path}` to be {expected_access}"),
got: e.kind().to_string(),
extra: serde_json::Map::new(),
}),
}
}
pub fn bad_arg(builtin: &str, expected: &str, got: &str) -> anyhow::Error {
anyhow::Error::new(SafetyError {
code: ErrorCode::BadArg,
message: format!("{}: expected {}, got {}", builtin, expected, got),
builtin: builtin.to_string(),
hint: format!("pass an argument matching: {}", expected),
approval: None,
did_you_mean: Vec::new(),
expected: expected.to_string(),
got: got.to_string(),
extra: serde_json::Map::new(),
})
}
pub fn bad_arg_with_hint(builtin: &str, expected: &str, got: &str, hint: &str) -> anyhow::Error {
anyhow::Error::new(SafetyError {
code: ErrorCode::BadArg,
message: format!("{}: expected {}, got {}", builtin, expected, got),
builtin: builtin.to_string(),
hint: hint.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: expected.to_string(),
got: got.to_string(),
extra: serde_json::Map::new(),
})
}
pub fn policy_deny(
builtin: &str,
message: impl Into<String>,
hint: impl Into<String>,
) -> anyhow::Error {
anyhow::Error::new(SafetyError {
code: ErrorCode::PolicyDeny,
message: message.into(),
builtin: builtin.to_string(),
hint: hint.into(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
})
}
pub fn os_denied(
builtin: &str,
message: impl Into<String>,
hint: impl Into<String>,
) -> anyhow::Error {
anyhow::Error::new(SafetyError {
code: ErrorCode::OsDenied,
message: message.into(),
builtin: builtin.to_string(),
hint: hint.into(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: "permission denied".to_string(),
extra: serde_json::Map::new(),
})
}
pub const ERROR_RESERVED_KEYS: &[&str] = &[
"code",
"message",
"retryable",
"builtin",
"error",
"did_you_mean",
"approval",
];
pub fn user_error(
message: String,
extra: serde_json::Map<String, serde_json::Value>,
hint: Option<String>,
) -> anyhow::Error {
anyhow::Error::new(SafetyError {
code: ErrorCode::User,
message,
builtin: "error".to_string(),
hint: hint.unwrap_or_else(|| {
"raised by error() in the script; catch it with try { } catch e { ... }, \
or fix the step that raised it"
.to_string()
}),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
extra,
})
}
pub fn assertion_failed(message: String) -> anyhow::Error {
anyhow::Error::new(SafetyError {
code: ErrorCode::Assert,
message,
builtin: "assert".to_string(),
hint: "raised by assert() in the script; catch it with try { } catch e { ... }, \
or fix the condition that failed"
.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
})
}
pub fn integer_overflow(operator: &str) -> anyhow::Error {
anyhow::Error::new(SafetyError {
code: ErrorCode::Overflow,
message: format!("integer overflow in operator {operator}"),
builtin: format!("operator {operator}"),
hint: "the result does not fit in a 64-bit Int; write one operand as a Float (2.0, \
not 2) to get a Float result, or split the computation"
.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
})
}
pub fn not_found_with(builtin: &str, message: String, hint: &str) -> anyhow::Error {
anyhow::Error::new(SafetyError {
code: ErrorCode::NotFound,
message,
builtin: builtin.to_string(),
hint: hint.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: "the destination folder to exist".to_string(),
got: "no such folder".to_string(),
extra: serde_json::Map::new(),
})
}
pub fn edit_not_found(builtin: &str, message: String, hint: &str) -> anyhow::Error {
anyhow::Error::new(SafetyError {
code: ErrorCode::NotFound,
message,
builtin: builtin.to_string(),
hint: hint.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: "the pattern to occur in the file".to_string(),
got: "not in the file".to_string(),
extra: serde_json::Map::new(),
})
}
pub fn value_not_representable(builtin: &str, message: String, hint: &str) -> anyhow::Error {
anyhow::Error::new(SafetyError {
code: ErrorCode::Overflow,
message,
builtin: builtin.to_string(),
hint: hint.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
})
}
pub fn bad_arg_in(builtin: &str, message: impl Into<String>, hint: &str) -> anyhow::Error {
anyhow::Error::new(SafetyError {
code: ErrorCode::BadArg,
message: message.into(),
builtin: builtin.to_string(),
hint: hint.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
})
}
pub fn bad_value_in(builtin: &str, message: impl Into<String>, hint: &str) -> anyhow::Error {
anyhow::Error::new(SafetyError {
code: ErrorCode::BadValue,
message: message.into(),
builtin: builtin.to_string(),
hint: hint.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
})
}
pub fn bad_value(message: impl Into<String>, hint: &str) -> anyhow::Error {
anyhow::Error::new(SafetyError {
code: ErrorCode::BadValue,
message: message.into(),
builtin: String::new(),
hint: hint.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
})
}
pub fn index_out_of_range(kind: &str, index: i64, len: usize, from_args: bool) -> anyhow::Error {
let hint = if len == 0 && from_args && crate::builtins::run_under_c() {
"-c takes no script arguments; put the code in a .ish file and run ish file.ish <arg>"
.to_string()
} else if len == 0 && from_args {
"the script was run with no arguments: ish script.ish <arg>".to_string()
} else if len == 0 {
format!("the {kind} is empty; check len(x) > 0 first")
} else {
format!(
"the {kind} has length {len}: valid indices are 0 to {} (or -1 to -{len} from the end)",
len - 1
)
};
anyhow::Error::new(SafetyError {
code: ErrorCode::BadValue,
message: format!("index {index} is out of range: the {kind} has length {len}"),
builtin: String::new(),
hint,
approval: None,
did_you_mean: Vec::new(),
expected: format!("an index from -{len} to {}", len.saturating_sub(1)),
got: index.to_string(),
extra: serde_json::Map::new(),
})
}
pub fn unknown_builtin(name: &str, candidates: Vec<String>) -> anyhow::Error {
unknown_builtin_hinted(name, candidates, None)
}
pub fn unknown_builtin_hinted(
name: &str,
candidates: Vec<String>,
specific: Option<String>,
) -> anyhow::Error {
let hint = if let Some(specific) = specific {
specific
} else if candidates.is_empty() {
"no builtin has a similar name; call ontology_manifest() to list the \
available categories"
.to_string()
} else {
format!("did you mean: {}", candidates.join(", "))
};
anyhow::Error::new(SafetyError {
code: ErrorCode::UnknownBuiltin,
message: format!("unknown builtin: {}", name),
builtin: name.to_string(),
hint,
approval: None,
did_you_mean: candidates,
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
})
}
pub fn unknown_module(name: &str, candidates: Vec<String>) -> anyhow::Error {
let hint = if candidates.is_empty() {
"no module has a similar name; call ontology_manifest() to list the available categories"
.to_string()
} else {
format!("did you mean: {}", candidates.join(", "))
};
anyhow::Error::new(SafetyError {
code: ErrorCode::UnknownBuiltin,
message: format!("unknown module: {}", name),
builtin: name.to_string(),
hint,
approval: None,
did_you_mean: candidates,
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
})
}
pub fn unknown_module_member(module: &str, member: &str, candidates: Vec<String>) -> anyhow::Error {
unknown_module_member_hinted(module, member, candidates, None)
}
pub fn unknown_module_member_hinted(
module: &str,
member: &str,
candidates: Vec<String>,
specific: Option<String>,
) -> anyhow::Error {
let hint = if let Some(specific) = specific {
specific
} else if candidates.is_empty() {
"no member of this module has a similar name; call ontology_manifest() to list the available builtins"
.to_string()
} else {
format!("did you mean: {}", candidates.join(", "))
};
anyhow::Error::new(SafetyError {
code: ErrorCode::UnknownBuiltin,
message: format!("module `{module}` has no member `{member}`"),
builtin: format!("{module}.{member}"),
hint,
approval: None,
did_you_mean: candidates,
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
})
}
pub fn unknown_name(name: &str, candidates: Vec<String>) -> anyhow::Error {
let hint = if candidates.is_empty() {
format!(
"`{name}` is not a variable, builtin or module; bind it with `{name} = ...`, \
or call a builtin as `{name}(...)`"
)
} else {
format!("did you mean: {}", candidates.join(", "))
};
anyhow::Error::new(SafetyError {
code: ErrorCode::UnknownName,
message: format!("unknown name `{name}`"),
builtin: String::new(),
hint,
approval: None,
did_you_mean: candidates,
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
})
}
pub fn unknown_field(field: &str, candidates: Vec<String>) -> anyhow::Error {
unknown_field_hinted(field, candidates, None)
}
pub fn unknown_field_hinted(
field: &str,
candidates: Vec<String>,
specific: Option<String>,
) -> anyhow::Error {
let hint = if let Some(specific) = specific {
specific
} else if candidates.is_empty() {
format!("no field named '{}' in this record", field)
} else {
format!("did you mean: {}", candidates.join(", "))
};
anyhow::Error::new(SafetyError {
code: ErrorCode::UnknownField,
message: format!("field '{}' not found in record", field),
builtin: String::new(),
hint,
approval: None,
did_you_mean: candidates,
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
})
}
pub fn record_keys_hint<'a>(keys: impl Iterator<Item = &'a str>) -> String {
let mut keys: Vec<&str> = keys.collect();
if keys.is_empty() {
return "the record has no fields".to_string();
}
keys.sort_unstable();
let mut listed = keys.iter().take(12).copied().collect::<Vec<_>>().join(", ");
if keys.len() > 12 {
listed.push_str(", …");
}
format!("the record has: {listed}")
}
pub fn missing_field_hint<'a>(field: &str, keys: impl Iterator<Item = &'a str>) -> String {
format!(
"this record has no field `{field}`; use get(r, \"{field}\") for an optional field (Null when missing). {}",
record_keys_hint(keys)
)
}
fn classify_uncoded(e: &anyhow::Error) -> Option<(ErrorCode, &'static str)> {
for cause in e.chain() {
if let Some(io) = cause.downcast_ref::<std::io::Error>() {
return Some(match io.kind() {
std::io::ErrorKind::NotFound => (
ErrorCode::NotFound,
"a file or directory the call needs does not exist; the message names it",
),
std::io::ErrorKind::PermissionDenied => (
ErrorCode::OsDenied,
"the operating system denied access; the shell did not refuse this",
),
_ => (
ErrorCode::Io,
"an I/O failure that is neither absence nor a permission refusal; the message names the condition",
),
});
}
#[cfg(not(target_arch = "wasm32"))]
if cause.downcast_ref::<reqwest::Error>().is_some() {
return Some((
ErrorCode::Io,
"an HTTP request failed; check the address and the network, then retry",
));
}
}
None
}
pub fn with_note(e: anyhow::Error, note: &str) -> anyhow::Error {
if let Some(se) = e.downcast_ref::<SafetyError>() {
let mut noted = se.clone();
noted.message = format!("{}; {note}", noted.message);
return anyhow::Error::new(noted);
}
let message = format!("{e:#}; {note}");
e.context(message)
}
pub fn code_of(e: &anyhow::Error) -> ErrorCode {
if let Some(se) = e.downcast_ref::<SafetyError>() {
return se.code;
}
classify_uncoded(e).map_or(ErrorCode::Unknown, |(code, _)| code)
}
pub fn ensure_structured(builtin: &str, e: anyhow::Error) -> anyhow::Error {
if let Some(se) = e.downcast_ref::<SafetyError>() {
let generic = matches!(se.code, ErrorCode::BadArg) && se.hint == ARG_ERR_HINT;
if !builtin.is_empty() && (se.builtin.is_empty() || generic) {
let mut filled = se.clone();
if filled.builtin.is_empty() {
filled.builtin = builtin.to_string();
if let Some(rest) = filled.message.strip_prefix(": ") {
filled.message = format!("{builtin}: {rest}");
}
}
if generic {
if let Some(sig) = crate::signature::signature_of(builtin) {
filled.hint = signature_arg_hint(builtin, sig);
}
}
return anyhow::Error::new(filled);
}
return e;
}
let message = e.to_string();
if let Some((code, hint)) = classify_uncoded(&e) {
return anyhow::Error::new(SafetyError {
code,
message: format!("{e:#}"),
builtin: builtin.to_string(),
hint: hint.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
});
}
anyhow::Error::new(SafetyError {
code: ErrorCode::Unknown,
message,
builtin: builtin.to_string(),
hint: format!(
"{} failed without a specific error code; inspect the message rather \
than retrying the same call",
builtin
),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
})
}
pub const ARG_ERR_HINT: &str = "check this builtin's required argument count and types";
fn signature_arg_hint(name: &str, sig: &crate::signature::Signature) -> String {
use crate::signature::Ty;
let mut types: Vec<&str> = Vec::new();
let subject = sig.subject.filter(|t| *t != Ty::Any);
let parameters = sig
.params
.iter()
.filter(|p| p.ty != Ty::Any)
.map(|p| p.ty.as_str());
for t in subject.into_iter().map(Ty::as_str).chain(parameters) {
if !types.contains(&t) {
types.push(t);
}
}
match types.split_last() {
None => format!("{name}: {}", sig.doc),
Some((last, [])) => format!("{name} takes {last}"),
Some((last, init)) => format!("{name} takes {} or {last}", init.join(", ")),
}
}
pub fn arg_err(message: impl Into<String>) -> anyhow::Error {
anyhow::Error::new(SafetyError {
code: ErrorCode::BadArg,
message: message.into(),
builtin: String::new(),
hint: ARG_ERR_HINT.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
})
}
pub fn lambda_arity(name: Option<&str>, params: &[String], got: usize) -> anyhow::Error {
let label = name.unwrap_or("lambda");
let plural = |k: usize| if k == 1 { "argument" } else { "arguments" };
let want = format!("{} {}", params.len(), plural(params.len()));
let received = format!("{got} {}", plural(got));
anyhow::Error::new(SafetyError {
code: ErrorCode::BadArg,
message: format!("{label} takes {want} ({}), got {got}", params.join(", ")),
builtin: String::new(),
hint: format!(
"a lambda takes exactly the parameters it was written with and has no \
defaults: call it with {want}, or pass null for a value you do not have \
and check for null in the body"
),
approval: None,
did_you_mean: Vec::new(),
expected: want,
got: received,
extra: serde_json::Map::new(),
})
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ApprovalDescriptor {
pub what: String,
pub builtin: String,
pub targets: Vec<String>,
pub blast_radius: Json,
pub reversible: bool,
pub token: String,
}
impl ApprovalDescriptor {
fn new(
what: &str,
builtin: &str,
targets: Vec<String>,
blast_radius: Json,
reversible: bool,
) -> Result<Self, ironcrypto::core_types::Error> {
let content = json!({
"what": what,
"builtin": builtin,
"targets": targets,
"blast_radius": blast_radius,
"reversible": reversible,
});
let mut hasher = Sha256::new();
hasher.update(content.to_string().as_bytes());
let digest = hasher.finalize()?;
let token = format!("apv_{}", &hex(&digest)[..16]);
Ok(Self {
what: what.to_string(),
builtin: builtin.to_string(),
targets,
blast_radius,
reversible,
token,
})
}
}
#[derive(Debug, Clone)]
pub struct SafetyError {
pub code: ErrorCode,
pub message: String,
pub builtin: String,
pub hint: String,
pub approval: Option<ApprovalDescriptor>,
pub did_you_mean: Vec<String>,
pub expected: String,
pub got: String,
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl SafetyError {
pub fn is_uncatchable_in_agent_mode(&self) -> bool {
current_mode() == Mode::Agent
&& matches!(
self.code,
ErrorCode::NeedsApproval | ErrorCode::OutsideWorkspace | ErrorCode::PolicyDeny
)
}
pub fn is_retryable(&self) -> bool {
match self.code {
ErrorCode::UnknownBuiltin => !self.did_you_mean.is_empty(),
code => code.retryable(),
}
}
pub fn to_json(&self) -> Json {
let scrub = |s: &str| {
if redaction_enabled() {
redact_str(s).0
} else {
s.to_string()
}
};
let mut err = json!({
"code": self.code.as_str(),
"message": scrub(&self.message),
"builtin": self.builtin,
"hint": scrub(&self.hint),
"retryable": self.is_retryable(),
});
if let Some(a) = &self.approval {
err["approval"] = serde_json::to_value(a).unwrap_or(Json::Null);
}
if !self.did_you_mean.is_empty() {
err["did_you_mean"] = serde_json::to_value(&self.did_you_mean).unwrap_or(Json::Null);
}
if !self.expected.is_empty() && !self.got.is_empty() {
err["expected"] = Json::String(scrub(&self.expected));
err["got"] = Json::String(scrub(&self.got));
}
if let Some(object) = err.as_object_mut() {
for (key, value) in &self.extra {
if object.contains_key(key) {
continue;
}
let value = match value {
Json::String(s) => Json::String(scrub(s)),
other => other.clone(),
};
object.insert(key.clone(), value);
}
}
json!({ "error": err })
}
}
impl std::fmt::Display for SafetyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.to_json())
}
}
impl std::error::Error for SafetyError {}
pub fn workspace_root() -> PathBuf {
if let Ok(w) = std::env::var("IRONSHELL_WORKSPACE") {
let w = PathBuf::from(w);
if w.is_absolute() {
w
} else {
std::path::absolute(&w).unwrap_or(w)
}
} else if let Ok(pinned) = std::env::var(PINNED_ROOT_ENV) {
PathBuf::from(pinned)
} else {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
}
const PINNED_ROOT_ENV: &str = "IRONSHELL_PINNED_ROOT";
pub fn pin_workspace_root() {
if current_mode() != Mode::Agent
|| std::env::var_os("IRONSHELL_WORKSPACE").is_some()
|| std::env::var_os(PINNED_ROOT_ENV).is_some()
{
return;
}
let Ok(cwd) = std::env::current_dir() else {
return;
};
let canon = cwd.canonicalize().unwrap_or(cwd);
std::env::set_var(PINNED_ROOT_ENV, strip_verbatim_prefix(&canon));
}
pub(crate) fn strip_verbatim_prefix(p: &Path) -> PathBuf {
let s = p.to_string_lossy();
match s.strip_prefix(r"\\?\") {
Some(rest) if !rest.starts_with("UNC\\") && rest.as_bytes().get(1) == Some(&b':') => {
PathBuf::from(rest)
}
_ => p.to_path_buf(),
}
}
pub fn is_audit_artifact(path: &str) -> bool {
let p = path.replace('\\', "/");
if p.trim_end_matches('/').is_empty() {
return false;
}
let sink = std::env::var_os("IRONSHELL_AUDIT_SINK")
.map(PathBuf::from)
.filter(|s| !s.as_os_str().is_empty());
let log = audit_path();
if sink.is_none() && log.is_none() {
return false;
}
let Some(candidates) = judged_forms(path) else {
return true;
};
let hit = |target: &Path| {
let forms = audit_forms(target);
candidates.iter().any(|c| forms.contains(c)) || same_existing_file(Path::new(path), target)
};
if let Some(sink) = &sink {
if hit(sink) {
return true;
}
}
match log {
Some(log) => {
if hit(&log) {
return true;
}
match log.parent() {
Some(dir) if dir.file_name().is_some_and(|n| n == ".ironshell") => {
let dirs = audit_forms(dir);
candidates.iter().any(|c| {
dirs.iter()
.any(|d| !d.is_empty() && (c == d || c.starts_with(&format!("{d}/"))))
})
}
_ => false,
}
}
None => false,
}
}
fn judged_forms(path: &str) -> Option<Vec<String>> {
let norm = |p: PathBuf| {
p.to_string_lossy()
.replace('\\', "/")
.trim_end_matches('/')
.to_ascii_lowercase()
};
let jail = norm(resolve_for_jail(path)?);
#[cfg(windows)]
let forms = vec![jail, norm(resolve_walked(win32_path(path))?)];
#[cfg(not(windows))]
let forms = vec![jail];
Some(forms)
}
fn audit_forms(path: &Path) -> Vec<String> {
let norm = |s: &str| {
s.replace('\\', "/")
.trim_end_matches('/')
.to_ascii_lowercase()
};
let mut forms = vec![norm(&path.to_string_lossy())];
if let Some(real) = resolve_existing_prefix(path) {
let real = norm(&real.to_string_lossy());
if !forms.contains(&real) {
forms.push(real);
}
}
forms
}
fn resolve_existing_prefix(path: &Path) -> Option<PathBuf> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir().ok()?.join(path)
};
let mut tail = Vec::new();
let mut cursor = absolute.as_path();
loop {
if let Ok(real) = std::fs::canonicalize(cursor) {
let mut out = real;
for part in tail.iter().rev() {
out.push(part);
}
return Some(out);
}
tail.push(cursor.file_name()?.to_os_string());
cursor = cursor.parent()?;
}
}
pub fn is_governance_file(path: &str) -> bool {
if path.replace('\\', "/").trim_end_matches('/').is_empty() {
return false;
}
let Some(candidates) = judged_forms(path) else {
return true;
};
let names = |target: &Path| {
let forms = audit_forms(target);
candidates.iter().any(|c| forms.contains(c)) || same_existing_file(Path::new(path), target)
};
let mut files = Vec::new();
match std::env::var_os("IRONSHELL_RBAC_CONFIG") {
Some(config) if !config.is_empty() => files.push(PathBuf::from(config)),
_ => {
let dir = workspace_root().join(".ironshell");
let dirs = audit_forms(&dir);
if candidates.iter().any(|c| {
dirs.iter()
.any(|d| !d.is_empty() && (c == d || c.starts_with(&format!("{d}/"))))
}) {
return true;
}
files.push(dir.join("rbac.toml"));
}
}
if let Some(key) = std::env::var_os("IRONSHELL_AUDIT_KEY_FILE") {
if !key.is_empty() {
files.push(PathBuf::from(key));
}
}
files.iter().any(|f| names(f))
}
pub fn protected_write_refusal(path: &str) -> Option<&'static str> {
let mode = current_mode();
if !jail_enforced(mode) {
return None;
}
if is_audit_artifact(path) {
Some("the audit log or its directory")
} else if is_governance_file(path) {
Some("a file that configures this shell's access control or audit key")
} else if mode == Mode::Agent && is_git_control_path(path) {
Some("inside a .git directory, whose config and hooks git runs")
} else {
None
}
}
pub fn is_git_control_path(path: &str) -> bool {
audit_forms(Path::new(path))
.iter()
.any(|form| form.split('/').any(|part| part == ".git"))
}
#[cfg(unix)]
fn same_existing_file(a: &Path, b: &Path) -> bool {
use std::os::unix::fs::MetadataExt;
match (std::fs::metadata(a), std::fs::metadata(b)) {
(Ok(x), Ok(y)) => x.dev() == y.dev() && x.ino() == y.ino(),
_ => false,
}
}
#[cfg(windows)]
fn same_existing_file(a: &Path, b: &Path) -> bool {
match (windows_file_id(a), windows_file_id(b)) {
(Some(x), Some(y)) => x == y,
_ => false,
}
}
#[cfg(not(any(unix, windows)))]
fn same_existing_file(_a: &Path, _b: &Path) -> bool {
false
}
#[cfg(windows)]
fn windows_file_id(path: &Path) -> Option<(u32, u64)> {
let info = by_handle_information(path)?;
let index = (u64::from(info.file_index_high) << 32) | u64::from(info.file_index_low);
Some((info.volume_serial_number, index))
}
#[cfg(windows)]
#[repr(C)]
#[derive(Default)]
#[allow(dead_code)]
struct ByHandleFileInformation {
file_attributes: u32,
creation_time: [u32; 2],
last_access_time: [u32; 2],
last_write_time: [u32; 2],
volume_serial_number: u32,
file_size_high: u32,
file_size_low: u32,
number_of_links: u32,
file_index_high: u32,
file_index_low: u32,
}
#[cfg(windows)]
#[link(name = "kernel32")]
extern "system" {
fn GetFileInformationByHandle(
h: *mut std::ffi::c_void,
info: *mut ByHandleFileInformation,
) -> i32;
}
#[cfg(windows)]
fn by_handle_information(path: &Path) -> Option<ByHandleFileInformation> {
use std::os::windows::fs::OpenOptionsExt;
const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
let file = std::fs::OpenOptions::new()
.access_mode(0)
.share_mode(0x7)
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
.open(path)
.ok()?;
handle_information(&file)
}
#[cfg(windows)]
fn handle_information(file: &std::fs::File) -> Option<ByHandleFileInformation> {
use std::os::windows::io::AsRawHandle;
let mut info = ByHandleFileInformation::default();
let ok = unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) };
if ok == 0 {
return None;
}
Some(info)
}
#[cfg(unix)]
pub(crate) fn hard_link_count(path: &Path) -> Option<u64> {
use std::os::unix::fs::MetadataExt;
let meta = std::fs::metadata(path).ok()?;
meta.is_file().then(|| meta.nlink())
}
#[cfg(windows)]
pub(crate) fn hard_link_count(path: &Path) -> Option<u64> {
if !std::fs::metadata(path).ok()?.is_file() {
return None;
}
by_handle_information(path).map(|info| u64::from(info.number_of_links))
}
#[cfg(not(any(unix, windows)))]
pub(crate) fn hard_link_count(_path: &Path) -> Option<u64> {
None
}
pub(crate) const REFUSE_LINKED_READS_ENV: &str = "IRONSHELL_REFUSE_LINKED_READS";
pub(crate) fn refuse_hard_linked_read(builtin: &str, path: &str) -> anyhow::Result<()> {
if std::env::var(REFUSE_LINKED_READS_ENV).as_deref() != Ok("1")
|| !jail_enforced(current_mode())
{
return Ok(());
}
let resolved = resolve_path_str(path);
for candidate in [path.to_string(), resolved] {
if let Some(links) = hard_link_count(Path::new(&candidate)).filter(|n| *n > 1) {
return Err(policy_deny(
builtin,
format!(
"'{path}' has {links} hard links, and {REFUSE_LINKED_READS_ENV} refuses reads through a hard link"
),
"read a copy that is not linked, or ask the operator to unset the setting",
));
}
}
Ok(())
}
fn hard_link_refusal(builtin: &str, path: &str, links: u64) -> anyhow::Error {
policy_deny(
builtin,
format!(
"'{path}' has {links} hard links, so writing it in place could change a file outside the workspace"
),
"remove the link or write a new file",
)
}
#[cfg(unix)]
fn handle_link_count(file: &std::fs::File) -> Option<u64> {
use std::os::unix::fs::MetadataExt;
let meta = file.metadata().ok()?;
meta.is_file().then(|| meta.nlink())
}
#[cfg(windows)]
fn handle_link_count(file: &std::fs::File) -> Option<u64> {
if !file.metadata().ok()?.is_file() {
return None;
}
handle_information(file).map(|info| u64::from(info.number_of_links))
}
#[cfg(not(any(unix, windows)))]
fn handle_link_count(_file: &std::fs::File) -> Option<u64> {
None
}
pub(crate) fn refuse_hard_linked_write(builtin: &str, path: &str) -> anyhow::Result<()> {
if !jail_enforced(current_mode()) {
return Ok(());
}
let resolved = resolve_path_str(path);
let mut candidates = vec![path.to_string()];
if resolved != path {
candidates.push(resolved);
}
for candidate in &candidates {
if let Some(links) = hard_link_count(Path::new(candidate)).filter(|n| *n > 1) {
return Err(hard_link_refusal(builtin, path, links));
}
}
Ok(())
}
pub(crate) fn open_in_place(
builtin: &str,
path: &Path,
append: bool,
) -> anyhow::Result<std::fs::File> {
use std::io::{Seek, SeekFrom};
let mut file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)?;
if jail_enforced(current_mode()) {
if let Some(links) = handle_link_count(&file).filter(|n| *n > 1) {
return Err(hard_link_refusal(
builtin,
&path.display().to_string(),
links,
));
}
}
if append {
file.seek(SeekFrom::End(0))?;
} else if file.metadata()?.is_file() {
file.set_len(0)?;
}
Ok(file)
}
pub(crate) fn in_place_error(builtin: &str, path: &str, e: anyhow::Error) -> anyhow::Error {
match e.downcast::<std::io::Error>() {
Ok(io) => fs_write_error(builtin, path, &io),
Err(other) => other,
}
}
pub(crate) fn jail_enforced(mode: Mode) -> bool {
mode == Mode::Agent || std::env::var("IRONSHELL_WORKSPACE").is_ok()
}
pub fn write_jail_refusal(path: &str) -> Option<String> {
let mode = current_mode();
if !jail_enforced(mode) {
return None;
}
if !within_workspace(path) {
return Some(format!("'{path}' is outside the workspace root"));
}
if is_audit_artifact(path) {
return Some(format!(
"'{path}' is the audit log or its directory, which is protected and cannot be written by a guarded builtin"
));
}
if is_governance_file(path) {
return Some(format!(
"'{path}' configures this shell's access control or audit key and cannot be written by a guarded builtin"
));
}
if mode == Mode::Agent && is_git_control_path(path) {
return Some(format!(
"'{path}' is inside a .git directory and cannot be written by an agent"
));
}
None
}
pub fn resolve_path_str(path: &str) -> String {
let p = std::path::Path::new(path);
if p.is_absolute() || !jail_enforced(current_mode()) {
path.to_string()
} else if std::env::var_os("IRONSHELL_WORKSPACE").is_some() {
join_native(workspace_root(), path)
} else {
join_native(
std::env::current_dir().unwrap_or_else(|_| workspace_root()),
path,
)
}
}
fn join_native(mut base: std::path::PathBuf, path: &str) -> String {
for part in std::path::Path::new(path).components() {
if part != std::path::Component::CurDir {
base.push(part.as_os_str());
}
}
let mut text = base.to_string_lossy().into_owned();
if (path.ends_with('/') || path.ends_with('\\')) && !text.ends_with(std::path::MAIN_SEPARATOR) {
text.push(std::path::MAIN_SEPARATOR);
}
text
}
pub fn within_workspace(path: &str) -> bool {
within_root(path, &workspace_root())
}
pub fn within_root(path: &str, base: &Path) -> bool {
let root = match base.canonicalize() {
Ok(r) => r,
Err(_) => return false,
};
#[cfg(windows)]
if !matches!(resolve_walked(win32_path(path)), Some(r) if r.starts_with(&root)) {
return false;
}
match resolve_for_jail(path) {
Some(resolved) => resolved.starts_with(&root),
None => false,
}
}
pub fn rewind_refused(path: &str) -> Option<String> {
if path.is_empty() || !jail_enforced(current_mode()) || within_workspace(path) {
return None;
}
Some(
"outside the workspace jail, so left as it is; rewind it outside agent mode, \
or with IRONSHELL_WORKSPACE covering it"
.to_string(),
)
}
const MAX_LINK_HOPS: usize = 40;
enum WalkSeg {
Prefix(std::ffi::OsString),
Root,
Parent,
Normal(std::ffi::OsString),
}
fn walk_segments(path: &Path) -> Vec<WalkSeg> {
use std::path::Component;
path.components()
.filter_map(|comp| match comp {
Component::Prefix(p) => Some(WalkSeg::Prefix(p.as_os_str().to_os_string())),
Component::RootDir => Some(WalkSeg::Root),
Component::CurDir => None,
Component::ParentDir => Some(WalkSeg::Parent),
Component::Normal(name) => Some(WalkSeg::Normal(name.to_os_string())),
})
.collect()
}
fn absolute_form(path: &str) -> PathBuf {
let given = Path::new(path);
if given.is_absolute() {
return given.to_path_buf();
}
let root = workspace_root();
let root = if root.is_absolute() {
root
} else {
std::env::current_dir().unwrap_or_default().join(root)
};
root.join(given)
}
fn physical_path(path: &str) -> Option<PathBuf> {
let mut pending: std::collections::VecDeque<WalkSeg> =
walk_segments(&absolute_form(path)).into();
let mut out = PathBuf::new();
let mut hops = 0usize;
while let Some(seg) = pending.pop_front() {
match seg {
WalkSeg::Prefix(prefix) => out = PathBuf::from(prefix),
WalkSeg::Root => out.push(std::path::Component::RootDir.as_os_str()),
WalkSeg::Parent => {
out.pop();
}
WalkSeg::Normal(name) => {
let next = out.join(&name);
match std::fs::symlink_metadata(&next) {
Ok(meta) if meta.file_type().is_symlink() => {
hops += 1;
if hops > MAX_LINK_HOPS {
return None;
}
let target = std::fs::read_link(&next).ok()?;
let target = if target.is_absolute() {
target
} else {
out.join(target)
};
let mut restart: std::collections::VecDeque<WalkSeg> =
walk_segments(&target).into();
restart.append(&mut pending);
pending = restart;
out = PathBuf::new();
}
Ok(_) => out = next,
Err(e)
if matches!(
e.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
out = next
}
Err(_) => return None,
}
}
}
}
Some(out)
}
fn resolve_for_jail(path: &str) -> Option<PathBuf> {
resolve_walked(physical_path(path)?)
}
#[cfg(windows)]
fn win32_path(path: &str) -> PathBuf {
let mut out = PathBuf::new();
for seg in walk_segments(&absolute_form(path)) {
match seg {
WalkSeg::Prefix(prefix) => out = PathBuf::from(prefix),
WalkSeg::Root => out.push(std::path::Component::RootDir.as_os_str()),
WalkSeg::Parent => {
out.pop();
}
WalkSeg::Normal(name) => out.push(name),
}
}
out
}
fn resolve_walked(walked: PathBuf) -> Option<PathBuf> {
for anc in walked.ancestors() {
if let Ok(canon) = anc.canonicalize() {
return Some(match walked.strip_prefix(anc) {
Ok(rest) => canon.join(rest),
Err(_) => canon,
});
}
}
Some(walked)
}
lazy_static! {
static ref GRANTED: Mutex<HashSet<String>> = Mutex::new(HashSet::new());
}
pub fn grant_approval(token: &str) {
if let Ok(mut g) = GRANTED.lock() {
g.insert(token.to_string());
}
}
pub fn revoke_approval(token: &str) {
if let Ok(mut g) = GRANTED.lock() {
g.remove(token);
}
}
pub fn is_approved(token: &str) -> bool {
is_token_approved(token)
}
fn approve_env_is_set() -> bool {
std::env::var("IRONSHELL_APPROVE")
.is_ok_and(|list| list.split(',').any(|t| !t.trim().is_empty()))
}
pub fn approval_instruction(token: &str) -> String {
if current_mode() == Mode::Agent {
format!(
"a human approves this token out of band: re-run with IRONSHELL_APPROVE={token} set by the operator"
)
} else {
format!("re-run with IRONSHELL_APPROVE={token} (or call approve(\"{token}\"))")
}
}
fn approval_refusal_hint(token: &str, network: bool) -> String {
let mut hint = approval_instruction(token);
if network {
hint.push_str(
", or have the operator allow the destination with IRONSHELL_NET_ALLOW=<host>",
);
}
if approve_env_is_set() {
hint.push_str(
"; IRONSHELL_APPROVE was set but none of its tokens approve this call; tokens are bound to the exact action and target",
);
}
hint
}
pub fn plan_needs_approval(
builtin: &str,
token: &str,
targets: Vec<String>,
operations: usize,
) -> SafetyError {
let what = "apply plan";
SafetyError {
code: ErrorCode::NeedsApproval,
message: format!("{builtin}: requires approval ({what})"),
builtin: builtin.to_string(),
hint: approval_refusal_hint(token, false),
approval: Some(ApprovalDescriptor {
what: what.to_string(),
builtin: builtin.to_string(),
targets,
blast_radius: json!({ "operations": operations }),
reversible: false,
token: token.to_string(),
}),
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
}
}
fn is_token_approved(token: &str) -> bool {
if truthy_env("IRONSHELL_APPROVE_ALL") {
return true;
}
if let Ok(list) = std::env::var("IRONSHELL_APPROVE") {
if list.split(',').map(str::trim).any(|t| t == token) {
return true;
}
}
GRANTED.lock().map(|g| g.contains(token)).unwrap_or(false)
}
lazy_static! {
static ref RBAC: Mutex<Option<std::sync::Arc<crate::auth::RbacManager>>> = Mutex::new(None);
static ref PRINCIPAL: Mutex<Option<String>> = Mutex::new(None);
}
pub fn set_rbac_manager(mgr: std::sync::Arc<crate::auth::RbacManager>) {
if let Ok(mut g) = RBAC.lock() {
*g = Some(mgr);
}
}
pub fn clear_rbac_manager() {
if let Ok(mut g) = RBAC.lock() {
*g = None;
}
}
pub fn set_principal(user_id: Option<String>) {
if let Ok(mut g) = PRINCIPAL.lock() {
*g = user_id;
}
}
pub fn current_principal() -> Option<String> {
PRINCIPAL.lock().ok().and_then(|g| g.clone())
}
pub fn init_rbac_from_env() {
if let Ok(p) = std::env::var("IRONSHELL_PRINCIPAL") {
if !p.is_empty() {
set_principal(Some(p));
}
}
let path = match std::env::var("IRONSHELL_RBAC_CONFIG") {
Ok(p) => PathBuf::from(p),
Err(_) => {
let default = workspace_root().join(".ironshell").join("rbac.toml");
if !default.exists() {
return;
}
default
}
};
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) => {
tracing::warn!(target: "security_audit", "rbac config read {}: {}", path.display(), e);
return;
}
};
match crate::auth::RbacManager::from_config_str(&text) {
Ok((mgr, principal)) => {
set_rbac_manager(std::sync::Arc::new(mgr));
if current_principal().is_none() {
if let Some(p) = principal {
set_principal(Some(p));
}
}
}
Err(e) => {
tracing::warn!(target: "security_audit", "rbac config parse {}: {}", path.display(), e);
}
}
}
fn rbac_authorized(effect: Effect, builtin: &str) -> Option<bool> {
let mgr = RBAC.lock().ok()?.clone()?;
let user = current_principal()?;
let by_effect = format!("effect:{}", effect.as_str());
let by_builtin = format!("builtin:{}", builtin);
if mgr.check_permission(&user, &by_effect) || mgr.check_permission(&user, &by_builtin) {
Some(true)
} else {
None
}
}
struct AuditState {
seq: u64,
last_hash: String,
path: Option<PathBuf>,
}
lazy_static! {
static ref AUDIT: Mutex<AuditState> = Mutex::new(AuditState {
seq: 0,
last_hash: GENESIS_HASH.to_string(),
path: None,
});
}
pub fn audit_path() -> Option<PathBuf> {
if let Ok(p) = std::env::var("IRONSHELL_AUDIT_LOG") {
return Some(PathBuf::from(p));
}
if current_mode() == Mode::Agent {
return Some(workspace_root().join(".ironshell").join("audit.log"));
}
None
}
fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{:02x}", b));
}
s
}
fn sha256_hex(s: &str) -> Result<String, ironcrypto::core_types::Error> {
Ok(hex(&Sha256::digest(s.as_bytes())?))
}
const AUDIT_KEY_MIN_BYTES: usize = 32;
lazy_static! {
static ref AUDIT_KEY: Option<Vec<u8>> = load_audit_key();
}
static AUDIT_KEY_REJECTED: Mutex<Option<&'static str>> = Mutex::new(None);
pub fn audit_key_rejected() -> Option<&'static str> {
AUDIT_KEY_REJECTED.lock().ok().and_then(|g| *g)
}
fn load_audit_key() -> Option<Vec<u8>> {
let from_env = std::env::var("IRONSHELL_AUDIT_KEY").ok();
if from_env.is_some() {
std::env::remove_var("IRONSHELL_AUDIT_KEY");
}
let (raw, var) = match from_env {
Some(v) => (v, "IRONSHELL_AUDIT_KEY"),
None => match std::env::var("IRONSHELL_AUDIT_KEY_FILE") {
Ok(path) => match std::fs::read_to_string(&path) {
Ok(c) => (c.trim().to_string(), "IRONSHELL_AUDIT_KEY_FILE"),
Err(e) => {
tracing::error!(
target: "security_audit",
"IRONSHELL_AUDIT_KEY_FILE={} could not be read ({}); the audit chain is \
UNKEYED and a rewrite by anyone with write access will verify clean",
path, e
);
return None;
}
},
Err(_) => return None,
},
};
let bytes = match hex::decode(raw.trim()) {
Ok(b) if b.len() >= AUDIT_KEY_MIN_BYTES => b,
_ => raw.trim().as_bytes().to_vec(),
};
if bytes.len() < AUDIT_KEY_MIN_BYTES {
if let Ok(mut g) = AUDIT_KEY_REJECTED.lock() {
*g = Some(var);
}
tracing::error!(
target: "security_audit",
"audit chain key is {} bytes, below the {}-byte minimum; the chain is UNKEYED",
bytes.len(), AUDIT_KEY_MIN_BYTES
);
let subject = if var == "IRONSHELL_AUDIT_KEY" {
"IRONSHELL_AUDIT_KEY is set but"
} else {
"IRONSHELL_AUDIT_KEY_FILE contents are"
};
eprintln!(
"{} shorter than {} characters, so the audit chain is not keyed",
subject, AUDIT_KEY_MIN_BYTES
);
return None;
}
Some(bytes)
}
pub fn audit_key() -> Option<&'static [u8]> {
AUDIT_KEY.as_deref()
}
fn audit_key_id(key: &[u8]) -> Result<String, ironcrypto::core_types::Error> {
let mut h = Sha256::new();
h.update(b"AetherShell-audit-key-id\x00");
h.update(key);
Ok(hex(&h.finalize()?)[..16].to_string())
}
fn chain_tag(key: Option<&[u8]>, s: &str) -> Result<String, ironcrypto::core_types::Error> {
match key {
Some(k) => {
use ironcrypto::core_types::traits::Mac as _;
let tag = ironcrypto::mac::HmacSha256::mac(k, s.as_bytes())?;
Ok(hex(&tag))
}
None => sha256_hex(s),
}
}
fn load_tail(path: &PathBuf, state: &mut AuditState) {
if let Ok(content) = std::fs::read_to_string(path) {
if let Some(last) = content.lines().rev().find(|l| !l.trim().is_empty()) {
if let Ok(obj) = serde_json::from_str::<Json>(last) {
if let Some(seq) = obj.get("seq").and_then(|v| v.as_u64()) {
state.seq = seq;
}
if let Some(h) = obj.get("entry_hash").and_then(|v| v.as_str()) {
state.last_hash = h.to_string();
}
}
}
}
}
fn read_last_line(path: &PathBuf) -> Option<String> {
use std::io::{Read, Seek, SeekFrom};
const WINDOW: u64 = 64 * 1024;
let mut f = std::fs::File::open(path).ok()?;
let len = f.metadata().ok()?.len();
if len == 0 {
return None;
}
let start = len.saturating_sub(WINDOW);
f.seek(SeekFrom::Start(start)).ok()?;
let mut buf = Vec::with_capacity((len - start) as usize);
f.read_to_end(&mut buf).ok()?;
let text = String::from_utf8_lossy(&buf);
text.lines()
.rev()
.find(|l| !l.trim().is_empty())
.map(|s| s.to_string())
}
fn read_last_entry_line(path: &PathBuf) -> Option<Json> {
use std::io::{Read, Seek, SeekFrom};
const WINDOW: u64 = 64 * 1024;
let mut f = std::fs::File::open(path).ok()?;
let len = f.metadata().ok()?.len();
if len == 0 {
return None;
}
let start = len.saturating_sub(WINDOW);
f.seek(SeekFrom::Start(start)).ok()?;
let mut buf = Vec::with_capacity((len - start) as usize);
f.read_to_end(&mut buf).ok()?;
let text = String::from_utf8_lossy(&buf);
text.lines()
.rev()
.filter(|l| !l.trim().is_empty())
.find_map(|l| serde_json::from_str::<Json>(l).ok())
}
fn writer_id() -> &'static str {
static ID: std::sync::OnceLock<String> = std::sync::OnceLock::new();
ID.get_or_init(|| {
let n: u64 = rand::random();
format!("{:016x}", n)
})
}
fn detect_tail_divergence(path: &PathBuf, state: &AuditState) -> Option<String> {
if state.seq == 0 {
return None;
}
match read_last_entry_line(path) {
None => {
if read_last_line(path).is_none() {
Some("the audit log is missing or empty".to_string())
} else {
Some("the audit log tail holds no complete entry".to_string())
}
}
Some(entry) => {
if let Some(w) = entry.get("writer").and_then(|v| v.as_str()) {
if w != writer_id() {
return None;
}
}
let observed = entry
.get("entry_hash")
.and_then(|v| v.as_str())
.unwrap_or_default();
if observed == state.last_hash {
None
} else {
Some(format!(
"the audit log tail is {} but this process last wrote {}",
if observed.is_empty() {
"an entry with no hash".to_string()
} else {
format!("entry {}", &observed[..observed.len().min(12)])
},
&state.last_hash[..state.last_hash.len().min(12)]
))
}
}
}
}
fn build_entry(
state: &AuditState,
builtin: &str,
effect: Effect,
decision: &str,
resource: &str,
detail: Json,
) -> Result<(u64, String, String), String> {
let seq = state.seq + 1;
let ts = chrono::Utc::now().to_rfc3339();
let principal = std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.ok();
let (resource, detail) = if redaction_enabled() {
let (r, _) = redact_str(resource);
let mut d = detail;
redact_json(&mut d);
(r, d)
} else {
(resource.to_string(), detail)
};
let key = audit_key();
let mut core = json!({
"seq": seq,
"ts": ts,
"principal": principal,
"builtin": builtin,
"effect": effect.as_str(),
"decision": decision,
"resource": resource,
"detail": detail,
"prev_hash": state.last_hash,
"writer": writer_id(),
});
if let Some(k) = key {
core["mac"] = json!("hmac-sha256");
core["key_id"] = json!(audit_key_id(k).map_err(|e| format!("audit chain: {e}"))?);
}
let entry_hash =
chain_tag(key, &core.to_string()).map_err(|e| format!("audit chain tag refused: {e}"))?;
let mut full = core;
full["entry_hash"] = json!(entry_hash);
Ok((seq, entry_hash, full.to_string()))
}
fn append_line(path: &PathBuf, line: &str) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
write_record(&mut f, line)
}
fn write_record(f: &mut std::fs::File, line: &str) -> std::io::Result<()> {
let record = format!("{}\n", line);
f.write_all(record.as_bytes())
}
fn mirror_to_sink(line: &str) -> std::io::Result<()> {
let sink = match std::env::var_os("IRONSHELL_AUDIT_SINK") {
Some(p) if !p.is_empty() => PathBuf::from(p),
_ => return Ok(()),
};
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&sink)?;
write_record(&mut f, line)
}
#[doc(hidden)]
pub fn __test_poison_audit_lock() {
let _ = std::thread::spawn(|| {
let _held = AUDIT.lock();
panic!("poisoning the audit lock for a test");
})
.join();
}
pub fn audit(
builtin: &str,
effect: Effect,
decision: &str,
resource: &str,
detail: Json,
) -> Result<(), String> {
let path = match audit_path() {
Some(p) => p,
None => return Ok(()),
};
let mut state = match AUDIT.lock() {
Ok(s) => s,
Err(_) => {
let reason =
"the audit log is unusable: an earlier call panicked while holding its lock";
if truthy_env("IRONSHELL_AUDIT_REQUIRED") {
return Err(reason.to_string());
}
tracing::error!(target: "security_audit", "audit entry dropped: {}", reason);
return Ok(());
}
};
if state.path.as_deref() != Some(path.as_path()) {
let previous = state.path.take();
state.seq = 0;
state.last_hash = GENESIS_HASH.to_string();
state.path = Some(path.clone());
load_tail(&path, &mut state);
if let (Some(old), Mode::Agent) = (previous, current_mode()) {
let reason = format!(
"the audit log path changed within one process, from {} to {}; this log starts a new chain here",
old.display(),
path.display()
);
if let Ok((mseq, mhash, mline)) = build_entry(
&state,
"audit_chain",
Effect::Privileged,
"log-path-changed",
&reason,
json!({ "previous_log": old.display().to_string() }),
) {
if append_line(&path, &mline).is_ok() {
state.seq = mseq;
state.last_hash = mhash;
}
}
}
}
if let Some(reason) = detect_tail_divergence(&path, &state) {
tracing::error!(
target: "security_audit",
"audit log integrity: {} — the log was modified outside the audit path", reason
);
let broken_from = state.last_hash.clone();
let broken_seq = state.seq;
state.seq = 0;
state.last_hash = GENESIS_HASH.to_string();
load_tail(&path, &mut state);
let (mseq, mhash, mline) = build_entry(
&state,
"audit_chain",
Effect::Privileged,
"tamper-detected",
&reason,
json!({
"expected_prev_hash": broken_from,
"entries_written_by_this_process": broken_seq,
}),
)?;
if append_line(&path, &mline).is_ok() {
state.seq = mseq;
state.last_hash = mhash;
}
}
let (seq, entry_hash, line) = build_entry(&state, builtin, effect, decision, resource, detail)?;
match append_line(&path, &line) {
Ok(()) => {
state.seq = seq;
state.last_hash = entry_hash;
if let Err(e) = mirror_to_sink(&line) {
if truthy_env("IRONSHELL_AUDIT_REQUIRED") {
return Err(format!("audit sink write failed: {}", e));
}
tracing::warn!(target: "security_audit", "audit sink write failed: {}", e);
}
Ok(())
}
Err(e) => {
if truthy_env("IRONSHELL_AUDIT_REQUIRED") {
Err(format!("audit write failed: {}", e))
} else {
tracing::warn!(target: "security_audit", "audit write failed: {}", e);
Ok(())
}
}
}
}
pub fn read_audit_tail(n: usize) -> Vec<Json> {
let path = match audit_path() {
Some(p) => p,
None => return Vec::new(),
};
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
let start = lines.len().saturating_sub(n);
lines[start..]
.iter()
.filter_map(|l| serde_json::from_str::<Json>(l).ok())
.collect()
}
pub fn verify_audit(path: &PathBuf) -> Result<u64, String> {
verify_audit_with(path, audit_key())
}
pub fn verify_audit_with(path: &PathBuf, key: Option<&[u8]>) -> Result<u64, String> {
let content = std::fs::read_to_string(path).map_err(|e| format!("read: {}", e))?;
let mut prev = GENESIS_HASH.to_string();
let mut expected_seq = 1u64;
let mut count = 0u64;
let mut writers: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for (lineno, line) in content.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let mut obj: Json =
serde_json::from_str(line).map_err(|e| format!("line {}: parse: {}", lineno + 1, e))?;
if let Some(w) = obj.get("writer").and_then(|v| v.as_str()) {
writers.insert(w.to_string());
}
let stored = obj
.get("entry_hash")
.and_then(|v| v.as_str())
.ok_or_else(|| format!("line {}: missing entry_hash", lineno + 1))?
.to_string();
if let Json::Object(m) = &mut obj {
m.remove("entry_hash");
}
let claims_mac = obj.get("mac").and_then(|v| v.as_str());
match (key, claims_mac) {
(Some(_), None) => {
return Err(format!(
"line {}: entry is unkeyed but this log is verified with a key — a rewritten chain looks exactly like this",
lineno + 1
))
}
(Some(_), Some(alg)) if alg != "hmac-sha256" => {
return Err(format!("line {}: unknown chain mac {:?}", lineno + 1, alg))
}
(None, Some(_)) => {
return Err(format!(
"line {}: entry is keyed but no chain key is configured; set IRONSHELL_AUDIT_KEY or IRONSHELL_AUDIT_KEY_FILE to verify it",
lineno + 1
))
}
_ => {}
}
let recomputed = chain_tag(key, &obj.to_string())
.map_err(|e| format!("line {}: chain tag refused: {e}", lineno + 1))?;
if !ironcrypto::core_types::ct::verify(stored.as_bytes(), recomputed.as_bytes()) {
return Err(if key.is_some() {
format!("line {}: entry_hash mismatch (tampered)", lineno + 1)
} else {
format!(
"line {}: entry_hash mismatch (the log was edited or corrupted; an unkeyed chain cannot tell which)",
lineno + 1
)
});
}
let prev_hash = obj.get("prev_hash").and_then(|v| v.as_str()).unwrap_or("");
if prev_hash != prev {
if writers.len() > 1 {
return Err(format!(
"line {}: this log interleaves entries from {} writers, so it is not a single chain; give each process its own IRONSHELL_AUDIT_LOG, or share one append-only sink via IRONSHELL_AUDIT_SINK",
lineno + 1,
writers.len()
));
}
return Err(format!("line {}: broken chain link", lineno + 1));
}
let seq = obj.get("seq").and_then(|v| v.as_u64()).unwrap_or(0);
if seq != expected_seq {
return Err(format!(
"line {}: non-monotonic seq (expected {}, got {})",
lineno + 1,
expected_seq,
seq
));
}
prev = stored;
expected_seq += 1;
count += 1;
}
Ok(count)
}
#[derive(Default)]
struct GovernorState {
start: Option<std::time::Instant>,
total: u64,
files: u64,
procs: u64,
net: u64,
}
lazy_static! {
static ref GOVERNOR: Mutex<GovernorState> = Mutex::new(GovernorState::default());
}
fn env_u64(name: &str) -> Option<u64> {
std::env::var(name)
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
}
pub fn budget_exceeded(builtin: &str, message: String, hint: &str) -> anyhow::Error {
anyhow::Error::new(budget_error(builtin, message, hint))
}
fn budget_error(builtin: &str, message: String, hint: &str) -> SafetyError {
SafetyError {
code: ErrorCode::BudgetExceeded,
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(),
extra: serde_json::Map::new(),
}
}
fn governor_admit(effect: Effect, builtin: &str) -> Result<(), SafetyError> {
if current_mode() != Mode::Agent {
return Ok(());
}
let mut g = match GOVERNOR.lock() {
Ok(g) => g,
Err(_) => return Ok(()),
};
let now = std::time::Instant::now();
let start = *g.start.get_or_insert(now);
if let Some(limit_ms) = env_u64("IRONSHELL_TIMEOUT_MS") {
let elapsed = now.duration_since(start).as_millis() as u64;
if elapsed > limit_ms {
return Err(budget_error(
builtin,
format!("wall-clock budget exhausted ({}ms > {}ms)", elapsed, limit_ms),
"the run exceeded IRONSHELL_TIMEOUT_MS; whoever launched the agent raises the limit, or a new process starts the clock",
));
}
}
g.total += 1;
if let Some(max) = env_u64("IRONSHELL_MAX_OPS") {
if g.total > max {
return Err(budget_error(
builtin,
format!("operation budget exhausted ({} > {})", g.total, max),
"whoever launched the agent raises IRONSHELL_MAX_OPS before launch",
));
}
}
match effect {
Effect::WriteLocal | Effect::Destructive => {
g.files += 1;
if let Some(max) = env_u64("IRONSHELL_MAX_FILES") {
if g.files > max {
return Err(budget_error(
builtin,
format!("file-operation budget exhausted ({} > {})", g.files, max),
"whoever launched the agent raises IRONSHELL_MAX_FILES before launch",
));
}
}
}
Effect::Process | Effect::Exec => {
g.procs += 1;
if let Some(max) = env_u64("IRONSHELL_MAX_PROCS") {
if g.procs > max {
return Err(budget_error(
builtin,
format!("process budget exhausted ({} > {})", g.procs, max),
"whoever launched the agent raises IRONSHELL_MAX_PROCS before launch",
));
}
}
}
Effect::Network => {
g.net += 1;
if let Some(max) = env_u64("IRONSHELL_MAX_NET") {
if g.net > max {
return Err(budget_error(
builtin,
format!("network-egress budget exhausted ({} > {})", g.net, max),
"whoever launched the agent raises IRONSHELL_MAX_NET before launch",
));
}
}
}
_ => {}
}
Ok(())
}
pub fn governor_reset() {
if let Ok(mut g) = GOVERNOR.lock() {
*g = GovernorState::default();
}
}
pub fn governor_snapshot() -> Json {
let g = match GOVERNOR.lock() {
Ok(g) => g,
Err(e) => e.into_inner(),
};
let elapsed_ms = g
.start
.map(|s| std::time::Instant::now().duration_since(s).as_millis() as u64)
.unwrap_or(0);
let lim = |n: &str| env_u64(n).map(|v| json!(v)).unwrap_or(Json::Null);
json!({
"active": current_mode() == Mode::Agent,
"elapsed_ms": elapsed_ms,
"used": { "ops": g.total, "files": g.files, "procs": g.procs, "net": g.net },
"limits": {
"max_ops": lim("IRONSHELL_MAX_OPS"),
"max_files": lim("IRONSHELL_MAX_FILES"),
"max_procs": lim("IRONSHELL_MAX_PROCS"),
"max_net": lim("IRONSHELL_MAX_NET"),
"timeout_ms": lim("IRONSHELL_TIMEOUT_MS"),
}
})
}
pub struct GuardCtx<'a> {
pub builtin: &'a str,
pub effect: Effect,
pub what: &'a str,
pub targets: Vec<String>,
pub blast_radius: Json,
pub reversible: bool,
pub fs_paths: bool,
}
impl<'a> GuardCtx<'a> {
pub fn new(builtin: &'a str, effect: Effect, what: &'a str, target: impl Into<String>) -> Self {
Self {
builtin,
effect,
what,
targets: vec![target.into()],
blast_radius: Json::Null,
reversible: false,
fs_paths: effect.is_filesystem(),
}
}
}
pub fn guard_exec(builtin: &str, command: impl Into<String>) -> Result<(), SafetyError> {
let command = command.into();
guard(GuardCtx {
builtin,
effect: Effect::Exec,
what: "exec",
targets: vec![command.clone()],
blast_radius: serde_json::json!({ "command": command }),
reversible: false,
fs_paths: false,
})
}
pub fn reject_option_like(builtin: &str, values: &[String]) -> anyhow::Result<()> {
for v in values {
if v.starts_with('-') {
return Err(anyhow::Error::new(SafetyError {
code: ErrorCode::BadArg,
message: format!(
"{}: refusing an option-like path argument: {:?}",
builtin, v
),
builtin: builtin.to_string(),
hint: "this position is a file path, and a leading '-' would be parsed as \
an option by the underlying tool — several of which can be made to \
run a command that way; pass './-name' if the file really is named \
that"
.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
}));
}
}
Ok(())
}
pub fn reject_unsafe_url(builtin: &str, url: &str) -> anyhow::Result<()> {
const ALLOWED_SCHEMES: &[&str] = &["http", "https"];
let err = |message: String, hint: &str| {
anyhow::Error::new(SafetyError {
code: ErrorCode::BadArg,
message,
builtin: builtin.to_string(),
hint: hint.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: format!(
"a URL with one of these schemes: {}",
ALLOWED_SCHEMES.join(", ")
),
got: url.to_string(),
extra: serde_json::Map::new(),
})
};
if let Some(c) = url.chars().find(|c| c.is_ascii_control()) {
return Err(err(
format!(
"{builtin}: refusing a URL containing a control character ({:#04x})",
c as u32
),
"a newline or carriage return separates commands in every shell; a URL \
cannot legitimately contain one",
));
}
if url.starts_with('-') {
return Err(err(
format!("{builtin}: refusing an option-like URL: {url:?}"),
"this position is passed positionally to `open`/`xdg-open`, and a leading \
'-' is parsed as an option — `open -a <app>` launches an arbitrary \
application",
));
}
let scheme = match url.split_once(':') {
Some((s, _)) if !s.is_empty() => s.to_ascii_lowercase(),
_ => {
return Err(err(
format!("{builtin}: refusing a URL with no scheme: {url:?}"),
"give an absolute URL such as 'https://example.com'; a bare path is \
dispatched by the desktop handler rather than opened as a web page",
))
}
};
if !ALLOWED_SCHEMES.contains(&scheme.as_str()) {
return Err(err(
format!("{builtin}: refusing the URL scheme {scheme:?}"),
"the desktop handler dispatches on the scheme, so a non-web scheme reaches \
whatever program is registered for it rather than a browser",
));
}
Ok(())
}
pub fn ps_quote(value: &str) -> PsLiteral {
let mut out = String::with_capacity(value.len() + 2);
out.push('\'');
for c in value.chars() {
if PS_SINGLE_QUOTES.contains(&c) {
out.push(c);
}
out.push(c);
}
out.push('\'');
PsLiteral(out)
}
const PS_SINGLE_QUOTES: [char; 5] = ['\'', '\u{2018}', '\u{2019}', '\u{201A}', '\u{201B}'];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PsLiteral(String);
impl std::fmt::Display for PsLiteral {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[macro_export]
macro_rules! ps_script {
($fmt:literal $(, $arg:expr)* $(,)?) => {{
$($crate::safety::ps_arg(&$arg);)*
format!($fmt $(, $arg)*)
}};
}
pub trait PsArg {}
impl PsArg for PsLiteral {}
impl PsArg for &'static str {}
macro_rules! impl_ps_arg_for_numbers {
($($t:ty),*) => { $(impl PsArg for $t {})* };
}
impl_ps_arg_for_numbers!(i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize);
#[doc(hidden)]
pub fn ps_arg<T: PsArg + ?Sized>(_: &T) {}
pub fn ps_bare_number(builtin: &str, value: &str) -> anyhow::Result<PsLiteral> {
let v = value.trim();
let (digits, suffix) = match v.find(|c: char| c.is_ascii_alphabetic()) {
Some(i) => (&v[..i], &v[i..]),
None => (v, ""),
};
let digits_ok = !digits.is_empty()
&& digits.chars().all(|c| c.is_ascii_digit() || c == '.')
&& digits.matches('.').count() <= 1;
let suffix_ok = matches!(
suffix.to_ascii_uppercase().as_str(),
"" | "KB" | "MB" | "GB" | "TB" | "PB"
);
if digits_ok && suffix_ok {
return Ok(PsLiteral(v.to_string()));
}
Err(anyhow::Error::new(SafetyError {
code: ErrorCode::BadArg,
message: format!("{}: expected a number or size, got {:?}", builtin, value),
builtin: builtin.to_string(),
hint: "this value is interpolated into a command unquoted, so it is \
restricted to digits with an optional KB/MB/GB/TB/PB suffix"
.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
}))
}
pub fn ps_join(values: impl IntoIterator<Item = PsLiteral>, sep: &str) -> PsLiteral {
PsLiteral(
values
.into_iter()
.map(|v| v.0)
.collect::<Vec<_>>()
.join(sep),
)
}
#[doc(hidden)]
pub fn applescript_arg<T: AppleScriptArg + ?Sized>(_: &T) {}
pub trait AppleScriptArg {}
impl AppleScriptArg for AppleScriptLiteral {}
impl AppleScriptArg for &'static str {}
#[macro_export]
macro_rules! applescript {
($fmt:literal $(, $arg:expr)* $(,)?) => {{
$($crate::safety::applescript_arg(&$arg);)*
format!($fmt $(, $arg)*)
}};
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppleScriptLiteral(String);
impl std::fmt::Display for AppleScriptLiteral {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
pub fn applescript_quote(value: &str) -> AppleScriptLiteral {
AppleScriptLiteral(format!(
"\"{}\"",
value.replace('\\', "\\\\").replace('"', "\\\"")
))
}
pub fn sql_literal(builtin: &str, value: &str) -> anyhow::Result<String> {
if value.contains('\0') {
return Err(anyhow::Error::new(SafetyError {
code: ErrorCode::BadArg,
message: format!("{builtin}: NUL byte in a SQL value"),
builtin: builtin.to_string(),
hint: "sqlite3 reads its SQL as a C string, so a NUL would silently truncate the statement; remove it"
.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
}));
}
Ok(format!("'{}'", value.replace('\'', "''")))
}
pub fn sql_identifier(builtin: &str, name: &str) -> anyhow::Result<String> {
let ok = !name.is_empty() && !name.chars().any(|c| c.is_control());
if !ok {
return Err(anyhow::Error::new(SafetyError {
code: ErrorCode::BadArg,
message: format!("{builtin}: {name:?} is not a valid table or column name: a name must be non-empty and hold no control characters"),
builtin: builtin.to_string(),
hint: "identifiers are interpolated into SQL and cannot be quoted as values; use any non-empty name without control characters (NUL, newline, tab and the like)"
.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: "a non-empty name without control characters".to_string(),
got: name.to_string(),
extra: serde_json::Map::new(),
}));
}
Ok(format!("\"{}\"", name.replace('"', "\"\"")))
}
pub fn sql_column_type(builtin: &str, spec: &str) -> anyhow::Result<String> {
const TYPES: &[&str] = &[
"INT",
"INTEGER",
"TINYINT",
"SMALLINT",
"MEDIUMINT",
"BIGINT",
"UNSIGNED",
"BIG",
"INT2",
"INT8",
"CHARACTER",
"VARCHAR",
"VARYING",
"NCHAR",
"NATIVE",
"NVARCHAR",
"TEXT",
"CLOB",
"BLOB",
"REAL",
"DOUBLE",
"PRECISION",
"FLOAT",
"NUMERIC",
"DECIMAL",
"BOOLEAN",
"DATE",
"DATETIME",
"TIMESTAMP",
];
const KEYWORDS: &[&str] = &[
"NOT",
"NULL",
"PRIMARY",
"KEY",
"AUTOINCREMENT",
"UNIQUE",
"DEFAULT",
"COLLATE",
"NOCASE",
"BINARY",
"RTRIM",
"ASC",
"DESC",
"CURRENT_TIMESTAMP",
"CURRENT_DATE",
"CURRENT_TIME",
"TRUE",
"FALSE",
];
let reject = |token: &str| {
anyhow::Error::new(SafetyError {
code: ErrorCode::BadArg,
message: format!("{builtin}: {token:?} is not a valid column type or constraint"),
builtin: builtin.to_string(),
hint: "this is interpolated into a CREATE TABLE statement, which sqlite3 \
executes as written — use a type name with an optional size and \
constraints such as 'NOT NULL' or 'PRIMARY KEY'; for a CHECK or \
REFERENCES clause, pass the whole column definition as a string \
instead of a record"
.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: "a column type such as 'TEXT', 'VARCHAR(255) NOT NULL'".to_string(),
got: spec.to_string(),
extra: serde_json::Map::new(),
})
};
fn without_size(token: &str) -> Option<&str> {
let Some(open) = token.find('(') else {
return Some(token);
};
let rest = &token[open + 1..];
let inner = rest.strip_suffix(')')?;
let digits_ok = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit());
let sized = match inner.split_once(',') {
Some((a, b)) => digits_ok(a.trim()) && digits_ok(b.trim()),
None => digits_ok(inner.trim()),
};
sized.then(|| &token[..open])
}
fn is_number(token: &str) -> bool {
let t = token.strip_prefix('-').unwrap_or(token);
match t.split_once('.') {
Some((a, b)) => {
!a.is_empty()
&& !b.is_empty()
&& a.chars().all(|c| c.is_ascii_digit())
&& b.chars().all(|c| c.is_ascii_digit())
}
None => !t.is_empty() && t.chars().all(|c| c.is_ascii_digit()),
}
}
fn is_plain_string_literal(token: &str) -> bool {
token.len() >= 2
&& token.starts_with('\'')
&& token.ends_with('\'')
&& !token[1..token.len() - 1].contains('\'')
}
let spec = spec.trim();
if spec.is_empty() {
return Err(reject(""));
}
let mut flattened = String::with_capacity(spec.len());
let mut depth = 0i32;
for c in spec.chars() {
match c {
'(' => depth += 1,
')' => depth -= 1,
_ => {}
}
if depth > 0 && c.is_whitespace() {
continue;
}
flattened.push(c);
}
for token in flattened.split_whitespace() {
if is_number(token) || is_plain_string_literal(token) {
continue;
}
let Some(word) = without_size(token) else {
return Err(reject(token));
};
let upper = word.to_ascii_uppercase();
if TYPES.contains(&upper.as_str()) || KEYWORDS.contains(&upper.as_str()) {
continue;
}
return Err(reject(token));
}
Ok(spec.to_string())
}
pub fn reject_sqlite_dot_command(builtin: &str, sql: &str) -> anyhow::Result<()> {
if sql.trim_start().starts_with('.') {
return Err(anyhow::Error::new(SafetyError {
code: ErrorCode::BadArg,
message: format!(
"{}: refusing a sqlite3 dot-command where SQL is expected",
builtin
),
builtin: builtin.to_string(),
hint: "`.system` and `.shell` run programs, so dot-commands are not \
accepted here; pass a SQL statement"
.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
}));
}
Ok(())
}
pub fn reject_sql_batch(builtin: &str, fragment: &str) -> anyhow::Result<()> {
#[derive(Clone, Copy, PartialEq)]
enum Lex {
Code,
Single,
Double,
Backtick,
Bracket,
Line,
Block,
}
let bytes = fragment.as_bytes();
let mut state = Lex::Code;
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
let next = bytes.get(i + 1).copied();
match state {
Lex::Code => match b {
b'\'' => state = Lex::Single,
b'"' => state = Lex::Double,
b'`' => state = Lex::Backtick,
b'[' => state = Lex::Bracket,
b'-' if next == Some(b'-') => {
state = Lex::Line;
i += 1;
}
b'/' if next == Some(b'*') => {
state = Lex::Block;
i += 1;
}
b';' => {
return Err(bad_arg(
builtin,
"one SQL statement: no `;` outside a string literal, quoted identifier or comment",
"a `;` that would begin a second statement",
));
}
_ => {}
},
Lex::Single if b == b'\'' => state = Lex::Code,
Lex::Double if b == b'"' => state = Lex::Code,
Lex::Backtick if b == b'`' => state = Lex::Code,
Lex::Bracket if b == b']' => state = Lex::Code,
Lex::Line if b == b'\n' => state = Lex::Code,
Lex::Block if b == b'*' && next == Some(b'/') => {
state = Lex::Code;
i += 1;
}
_ => {}
}
i += 1;
}
Ok(())
}
pub fn net_allowlist() -> Option<String> {
std::env::var("IRONSHELL_NET_ALLOW")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn egress_denied(
builtin: &str,
effect: Effect,
resource: &str,
allow: &str,
detail: String,
) -> SafetyError {
let _ = audit(
builtin,
effect,
"deny_egress",
resource,
json!({ "allow": allow, "detail": detail }),
);
SafetyError {
code: ErrorCode::PolicyDeny,
message: format!("{}: egress denied: {}", builtin, detail),
builtin: builtin.to_string(),
hint: format!(
"IRONSHELL_NET_ALLOW permits only: {allow}; name an allowed host, or ask the operator to add this one"
),
approval: None,
did_you_mean: Vec::new(),
expected: format!("a destination in {allow}"),
got: detail,
extra: serde_json::Map::new(),
}
}
const EGRESS_JUDGED_BY_BODY: &[&str] = &[
"pkg_changelog",
"scp_upload",
"scp_download",
"git_fetch",
"git_pull",
"git_push",
"skopeo_copy",
"skopeo_inspect",
"trivy_scan",
"trivy_image",
"gh_repo",
"gh_repo_cmd",
];
pub fn check_egress(builtin: &str, targets: &[String]) -> Result<(), SafetyError> {
if policy_permissive() {
return Ok(());
}
let Some(allow) = net_allowlist() else {
return Ok(());
};
match egress_permitted(targets, &allow) {
Ok(()) => Ok(()),
Err(detail) => Err(egress_denied(
builtin,
Effect::Exec,
&targets.join(", "),
&allow,
detail,
)),
}
}
pub fn check_egress_unknown(builtin: &str, reason: &str) -> Result<(), SafetyError> {
if policy_permissive() {
return Ok(());
}
let Some(allow) = net_allowlist() else {
return Ok(());
};
if allow.split([';', ',']).any(|e| e.trim() == "*") {
return Ok(());
}
Err(egress_denied(
builtin,
Effect::Exec,
"",
&allow,
format!("{reason}; an allowlist is set, so the call is refused"),
))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MirrorConfig {
DaemonJson,
RegistriesConf,
}
const MIRROR_DROPIN_MAX_FILES: usize = 64;
fn nonempty_path(
var: &dyn Fn(&str) -> Option<std::ffi::OsString>,
name: &str,
) -> Option<std::path::PathBuf> {
var(name)
.filter(|v| !v.is_empty())
.map(std::path::PathBuf::from)
}
fn home_dir(var: &dyn Fn(&str) -> Option<std::ffi::OsString>) -> Option<std::path::PathBuf> {
nonempty_path(var, "HOME").or_else(|| nonempty_path(var, "USERPROFILE"))
}
fn user_config_dirs(var: &dyn Fn(&str) -> Option<std::ffi::OsString>) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
if let Some(xdg) = nonempty_path(var, "XDG_CONFIG_HOME") {
out.push(xdg);
}
if let Some(home_config) = home_dir(var).map(|h| h.join(".config")) {
if !out.contains(&home_config) {
out.push(home_config);
}
}
out
}
fn docker_config_json(
var: &dyn Fn(&str) -> Option<std::ffi::OsString>,
) -> Option<std::path::PathBuf> {
match nonempty_path(var, "DOCKER_CONFIG") {
Some(dir) => Some(dir.join("config.json")),
None => home_dir(var).map(|h| h.join(".docker").join("config.json")),
}
}
fn var_is_set(var: &dyn Fn(&str) -> Option<std::ffi::OsString>, name: &str) -> bool {
var(name).is_some_and(|v| !v.is_empty())
}
pub fn container_mirror_paths(
var: impl Fn(&str) -> Option<std::ffi::OsString>,
) -> Vec<(MirrorConfig, std::path::PathBuf)> {
container_mirror_paths_at(&var, std::path::Path::new("/"))
}
fn container_mirror_paths_at(
var: &dyn Fn(&str) -> Option<std::ffi::OsString>,
system: &std::path::Path,
) -> Vec<(MirrorConfig, std::path::PathBuf)> {
let mut out = vec![(
MirrorConfig::DaemonJson,
system.join("etc").join("docker").join("daemon.json"),
)];
if let Some(pd) = nonempty_path(var, "ProgramData") {
let path = pd.join("docker").join("config").join("daemon.json");
out.push((MirrorConfig::DaemonJson, path));
}
out.push((
MirrorConfig::RegistriesConf,
system
.join("etc")
.join("containers")
.join("registries.conf"),
));
if let Some(h) = home_dir(var) {
out.push((
MirrorConfig::DaemonJson,
h.join(".docker").join("daemon.json"),
));
}
for c in user_config_dirs(var) {
out.push((
MirrorConfig::RegistriesConf,
c.join("containers").join("registries.conf"),
));
out.push((
MirrorConfig::DaemonJson,
c.join("docker").join("daemon.json"),
));
}
out
}
fn container_dropin_dirs(
var: &dyn Fn(&str) -> Option<std::ffi::OsString>,
system: &std::path::Path,
) -> Vec<std::path::PathBuf> {
let mut out = vec![system
.join("etc")
.join("containers")
.join("registries.conf.d")];
for c in user_config_dirs(var) {
out.push(c.join("containers").join("registries.conf.d"));
}
out
}
const MIRROR_UNREADABLE: &str =
"a docker daemon.json or containers registries.conf cannot be read, so a registry mirror in it cannot be ruled out";
const MIRROR_UNPARSED: &str =
"a docker daemon.json or containers registries.conf does not parse, so a registry mirror in it cannot be ruled out";
const MIRROR_NAMED: &str =
"a docker daemon.json or containers registries.conf names a registry mirror or an unqualified search list, so the registry the pull reaches cannot be read";
const MIRROR_ENV: &str =
"CONTAINERS_REGISTRIES_CONF names a registries.conf this check does not read, so a registry mirror in it cannot be ruled out";
const MIRROR_TOO_MANY: &str =
"a containers registries.conf.d holds more drop-in files than the check reads, so a registry mirror in them cannot be ruled out";
const MIRROR_REMOTE_DOCKER: &str =
"DOCKER_HOST or DOCKER_CONTEXT names a daemon whose config this check does not read, so a registry mirror in it cannot be ruled out";
const MIRROR_CONTEXT_DOCKER: &str =
"the docker config.json names a current context, which selects a daemon whose config this check does not read, so a registry mirror in it cannot be ruled out";
const MIRROR_REMOTE_PODMAN: &str =
"CONTAINER_HOST or CONTAINER_CONNECTION names an engine whose config this check does not read, so a registry mirror in it cannot be ruled out";
pub fn daemon_json_mirrors(text: &str) -> Result<bool, &'static str> {
let value: serde_json::Value = serde_json::from_str(text).map_err(|_| MIRROR_UNPARSED)?;
let object = value.as_object().ok_or(MIRROR_UNPARSED)?;
match object.get("registry-mirrors") {
None | Some(serde_json::Value::Null) => Ok(false),
Some(serde_json::Value::Array(list)) => Ok(!list.is_empty()),
Some(_) => Err(MIRROR_UNPARSED),
}
}
pub fn registries_conf_redirects(text: &str) -> Result<bool, &'static str> {
fn in_use(v: Option<&toml::Value>) -> Result<bool, &'static str> {
match v {
None => Ok(false),
Some(toml::Value::Array(list)) => Ok(!list.is_empty()),
Some(_) => Err(MIRROR_UNPARSED),
}
}
let doc: toml::Table = text.parse().map_err(|_| MIRROR_UNPARSED)?;
if in_use(doc.get("unqualified-search-registries"))? {
return Ok(true);
}
let v1_search = doc
.get("registries")
.and_then(|r| r.get("search"))
.and_then(|s| s.get("registries"));
if in_use(v1_search)? {
return Ok(true);
}
match doc.get("registry") {
None => Ok(false),
Some(toml::Value::Array(entries)) => {
for entry in entries {
let table = entry.as_table().ok_or(MIRROR_UNPARSED)?;
match table.get("mirror") {
None => {}
Some(toml::Value::Array(list)) if list.is_empty() => {}
Some(_) => return Ok(true),
}
}
Ok(false)
}
Some(_) => Err(MIRROR_UNPARSED),
}
}
fn mirror_refusal_in(paths: &[(MirrorConfig, std::path::PathBuf)]) -> Result<(), &'static str> {
for (format, path) in paths {
if names_a_remote_share(&path.to_string_lossy()) {
return Err(MIRROR_UNREADABLE);
}
match path.try_exists() {
Ok(false) => continue,
Ok(true) => {}
Err(_) => return Err(MIRROR_UNREADABLE),
}
let text = read_git_file(path).ok_or(MIRROR_UNREADABLE)?;
let names = match format {
MirrorConfig::DaemonJson => daemon_json_mirrors(&text),
MirrorConfig::RegistriesConf => registries_conf_redirects(&text),
};
if names? {
return Err(MIRROR_NAMED);
}
}
Ok(())
}
fn dropin_files(
dirs: &[std::path::PathBuf],
) -> Result<Vec<(MirrorConfig, std::path::PathBuf)>, &'static str> {
let mut out = Vec::new();
for dir in dirs {
if names_a_remote_share(&dir.to_string_lossy()) {
return Err(MIRROR_UNREADABLE);
}
match dir.try_exists() {
Ok(false) => continue,
Ok(true) => {}
Err(_) => return Err(MIRROR_UNREADABLE),
}
let mut names = Vec::new();
for entry in std::fs::read_dir(dir).map_err(|_| MIRROR_UNREADABLE)? {
let name = entry.map_err(|_| MIRROR_UNREADABLE)?.file_name();
if name.to_string_lossy().ends_with(".conf") {
names.push(name);
}
}
names.sort();
for name in names {
if out.len() == MIRROR_DROPIN_MAX_FILES {
return Err(MIRROR_TOO_MANY);
}
out.push((MirrorConfig::RegistriesConf, dir.join(name)));
}
}
Ok(out)
}
fn docker_context_refusal(
var: &dyn Fn(&str) -> Option<std::ffi::OsString>,
) -> Result<(), &'static str> {
let Some(path) = docker_config_json(var) else {
return Ok(());
};
if names_a_remote_share(&path.to_string_lossy()) {
return Err(MIRROR_UNREADABLE);
}
match path.try_exists() {
Ok(false) => return Ok(()),
Ok(true) => {}
Err(_) => return Err(MIRROR_UNREADABLE),
}
let text = read_git_file(&path).ok_or(MIRROR_UNREADABLE)?;
let value: serde_json::Value = serde_json::from_str(&text).map_err(|_| MIRROR_UNPARSED)?;
let object = value.as_object().ok_or(MIRROR_UNPARSED)?;
match object.get("currentContext") {
None | Some(serde_json::Value::Null) => Ok(()),
Some(serde_json::Value::String(c)) if c.is_empty() || c == "default" => Ok(()),
Some(serde_json::Value::String(_)) => Err(MIRROR_CONTEXT_DOCKER),
Some(_) => Err(MIRROR_UNPARSED),
}
}
pub fn container_mirror_refusal(
var: impl Fn(&str) -> Option<std::ffi::OsString>,
) -> Result<(), &'static str> {
let env: &dyn Fn(&str) -> Option<std::ffi::OsString> = &var;
if var_is_set(env, "CONTAINERS_REGISTRIES_CONF") {
return Err(MIRROR_ENV);
}
let system = std::path::Path::new("/");
mirror_refusal_in(&container_mirror_paths_at(env, system))?;
mirror_refusal_in(&dropin_files(&container_dropin_dirs(env, system))?)
}
pub fn runtime_mirror_refusal_at(
builtin: &str,
var: impl Fn(&str) -> Option<std::ffi::OsString>,
system: &std::path::Path,
) -> Result<(), &'static str> {
let env: &dyn Fn(&str) -> Option<std::ffi::OsString> = &var;
if builtin.starts_with("docker") {
if var_is_set(env, "DOCKER_HOST") || var_is_set(env, "DOCKER_CONTEXT") {
return Err(MIRROR_REMOTE_DOCKER);
}
docker_context_refusal(env)?;
let mut paths = container_mirror_paths_at(env, system);
paths.retain(|(format, _)| *format == MirrorConfig::DaemonJson);
return mirror_refusal_in(&paths);
}
if var_is_set(env, "CONTAINER_HOST") || var_is_set(env, "CONTAINER_CONNECTION") {
return Err(MIRROR_REMOTE_PODMAN);
}
if var_is_set(env, "CONTAINERS_REGISTRIES_CONF") {
return Err(MIRROR_ENV);
}
let mut paths = container_mirror_paths_at(env, system);
paths.retain(|(format, _)| *format == MirrorConfig::RegistriesConf);
mirror_refusal_in(&paths)?;
mirror_refusal_in(&dropin_files(&container_dropin_dirs(env, system))?)
}
pub fn runtime_mirror_refusal(
builtin: &str,
var: impl Fn(&str) -> Option<std::ffi::OsString>,
) -> Result<(), &'static str> {
runtime_mirror_refusal_at(builtin, var, std::path::Path::new("/"))
}
pub fn check_container_mirror_egress(builtin: &str) -> Result<(), SafetyError> {
if policy_permissive() {
return Ok(());
}
let Some(allow) = net_allowlist() else {
return Ok(());
};
if allow.split([';', ',']).any(|e| e.trim() == "*") {
return Ok(());
}
match runtime_mirror_refusal(builtin, |name| std::env::var_os(name)) {
Ok(()) => Ok(()),
Err(reason) => check_egress_unknown(builtin, reason),
}
}
const SSH_CONFIG_MAX_BYTES: u64 = 1024 * 1024;
pub fn ssh_egress_host(builtin: &str, host: &str) -> Result<String, SafetyError> {
let name = host.rsplit('@').next().unwrap_or(host);
if policy_permissive() || net_allowlist().is_none() {
return Ok(name.to_string());
}
let config = match read_ssh_config() {
Ok(Some(text)) => text,
Ok(None) => return Ok(name.to_string()),
Err(reason) => {
check_egress_unknown(builtin, reason)?;
return Ok(name.to_string());
}
};
match ssh_config_destination(&config, name) {
Ok(Some(resolved)) => Ok(resolved),
Ok(None) => Ok(name.to_string()),
Err(reason) => {
check_egress_unknown(builtin, reason)?;
Ok(name.to_string())
}
}
}
fn read_ssh_config() -> Result<Option<String>, &'static str> {
use std::io::Read;
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map_err(|_| "no home directory is set, so ~/.ssh/config cannot be found")?;
let path = std::path::Path::new(&home).join(".ssh").join("config");
let file = match std::fs::File::open(&path) {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(_) => {
return Err("~/.ssh/config cannot be opened, so the host it reaches cannot be read")
}
};
let mut buf = Vec::new();
if file
.take(SSH_CONFIG_MAX_BYTES + 1)
.read_to_end(&mut buf)
.is_err()
{
return Err("~/.ssh/config cannot be read, so the host it reaches cannot be read");
}
if buf.len() as u64 > SSH_CONFIG_MAX_BYTES {
return Err("~/.ssh/config is over 1 MiB, so the host it reaches cannot be read");
}
Ok(Some(String::from_utf8_lossy(&buf).into_owned()))
}
pub fn ssh_config_destination(config: &str, name: &str) -> Result<Option<String>, &'static str> {
const INCLUDE: &str = "~/.ssh/config has an Include, so the host it reaches cannot be read";
const MATCH: &str = "~/.ssh/config has a Match block, so the host it reaches cannot be read";
const PROXY: &str =
"~/.ssh/config has a ProxyJump or ProxyCommand that applies to this host, so the host it reaches cannot be read";
const WILDCARD: &str =
"~/.ssh/config sets HostName in a wildcard Host block that applies to this host, so the host it reaches cannot be read";
const TOKEN: &str =
"the HostName in ~/.ssh/config is empty or uses a % token, so the host it reaches cannot be read";
let name = name.trim().to_ascii_lowercase();
let mut applies = true;
let mut block_wildcard = false;
let mut resolved: Option<String> = None;
for raw in config.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let end = line
.find(|c: char| c.is_whitespace() || c == '=')
.unwrap_or(line.len());
let key = line[..end].to_ascii_lowercase();
let value = line[end..]
.trim_start_matches(|c: char| c.is_whitespace() || c == '=')
.trim();
match key.as_str() {
"include" => return Err(INCLUDE),
"match" => return Err(MATCH),
"proxyjump" | "proxycommand" if applies => return Err(PROXY),
"host" => {
let mut positive = false;
let mut negated = false;
let mut wildcard = false;
for pattern in value.split_whitespace().map(str::to_ascii_lowercase) {
match pattern.strip_prefix('!') {
Some(not) => negated |= ssh_glob(not, &name),
None => {
if ssh_glob(&pattern, &name) {
positive = true;
wildcard |= pattern.contains(['*', '?']);
}
}
}
}
applies = positive && !negated;
block_wildcard = applies && wildcard;
}
"hostname" if applies => {
if block_wildcard {
return Err(WILDCARD);
}
let host = value.trim_matches('"').trim();
if host.is_empty() || host.contains('%') {
return Err(TOKEN);
}
resolved.get_or_insert_with(|| host.to_string());
}
_ => {}
}
}
Ok(resolved)
}
fn ssh_glob(pattern: &str, name: &str) -> bool {
fn go(p: &[u8], n: &[u8]) -> bool {
match p.split_first() {
None => n.is_empty(),
Some((b'*', rest)) => (0..=n.len()).any(|i| go(rest, &n[i..])),
Some((b'?', rest)) => !n.is_empty() && go(rest, &n[1..]),
Some((c, rest)) => n.first() == Some(c) && go(rest, &n[1..]),
}
}
go(pattern.as_bytes(), name.as_bytes())
}
pub fn destination_host(s: &str) -> Option<String> {
let s = s.trim();
if let Some(rest) = s.split_once("://").map(|(_, r)| r) {
let authority = rest.split(['/', '?', '#']).next()?;
let host_port = authority.rsplit('@').next()?;
let host = if let Some(v6) = host_port.strip_prefix('[') {
v6.split(']').next()?
} else {
host_port.split(':').next()?
};
return (!host.is_empty()).then(|| host.to_ascii_lowercase());
}
let hostish = |h: &str| {
!h.is_empty()
&& h.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
&& (h.contains('.') || h.eq_ignore_ascii_case("localhost"))
&& !h.starts_with('.')
&& !h.starts_with('-')
};
if let Some((_, after)) = s.split_once('@') {
let host = after.split(':').next().unwrap_or("");
return hostish(host).then(|| host.to_ascii_lowercase());
}
let host = s.rsplit_once(':').map_or(s, |(h, p)| {
if p.chars().all(|c| c.is_ascii_digit()) && !p.is_empty() {
h
} else {
s
}
});
hostish(host).then(|| host.to_ascii_lowercase())
}
fn host_matches(host: &str, entry: &str) -> bool {
let entry = entry
.trim()
.trim_start_matches("*.")
.trim_start_matches('.');
entry == "*" || host == entry || host.ends_with(&format!(".{entry}"))
}
pub fn egress_permitted(targets: &[String], allow: &str) -> Result<(), String> {
let entries: Vec<String> = allow
.split([';', ','])
.map(|e| e.trim().to_ascii_lowercase())
.filter(|e| !e.is_empty())
.collect();
if entries.iter().any(|e| e == "*") {
return Ok(());
}
let urls: Vec<&String> = targets.iter().filter(|t| t.contains("://")).collect();
let hosts: Vec<String> = if urls.is_empty() {
targets
.iter()
.filter(|t| names_a_remote_share(t) || !std::path::Path::new(t.as_str()).exists())
.filter_map(|t| destination_host(t))
.collect()
} else {
urls.iter().filter_map(|t| destination_host(t)).collect()
};
if hosts.is_empty() {
return Err("the destination host could not be determined from the arguments".to_string());
}
match hosts
.iter()
.find(|h| !entries.iter().any(|e| host_matches(h, e)))
{
Some(h) => Err(format!("'{h}' is not in the allowlist")),
None => Ok(()),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GitRemote {
Local,
Host { host: String, ssh: bool },
Unknown(&'static str),
}
const GIT_TRANSPORT_UNKNOWN: &str =
"the remote URL uses an ext:: or fd:: transport, which runs a program";
const GIT_SCHEME_UNKNOWN: &str = "the remote URL uses a scheme this check does not read";
const GIT_NO_HOST: &str = "the remote URL names no host";
const GIT_SHARE_UNKNOWN: &str = "the remote is a Windows share path, which opens a host";
const GIT_ENV_OVERRIDE: &str = "a GIT_CONFIG*, GIT_DIR setting is in the environment, so the remote cannot be read from .git/config";
const GIT_NO_REPO: &str =
"no git repository is found from the working directory, so the remote cannot be read";
const GIT_POINTER_MALFORMED: &str = "the .git here is a worktree or submodule file, or its commondir, that does not point to a readable git directory, so the remote cannot be read";
const GIT_CONFIG_UNREADABLE: &str = ".git/config cannot be read, so the remote cannot be read";
const GIT_HEAD_UNREADABLE: &str =
".git/HEAD cannot be read, so the branch and its remote cannot be read";
const GIT_CONFIG_UNPARSED: &str =
".git/config has a line this reader does not parse, so the remote cannot be read";
const GIT_CONFIG_ESCAPE: &str =
".git/config has a backslash in a value or name, so the remote cannot be read";
const GIT_CONFIG_INCLUDE: &str = ".git/config has an include, so the remote cannot be read";
const GIT_REMOTE_VCS: &str =
"the remote sets remote.<name>.vcs, a remote helper, so the transport cannot be read";
const GIT_REMOTE_MISSING: &str =
"the remote the command uses has no [remote] section in .git/config";
const GIT_REMOTE_NO_URL: &str = "the remote the command uses has no URL in .git/config";
pub fn git_remote_target(url: &str) -> GitRemote {
let url = url.trim();
if url.starts_with("ext::") || url.starts_with("fd::") {
return GitRemote::Unknown(GIT_TRANSPORT_UNKNOWN);
}
if names_a_remote_share(url) {
return GitRemote::Unknown(GIT_SHARE_UNKNOWN);
}
if let Some((scheme, _)) = url.split_once("://") {
return match scheme.to_ascii_lowercase().as_str() {
"file" => GitRemote::Local,
"ssh" | "git+ssh" | "ssh+git" => match destination_host(url) {
Some(host) => GitRemote::Host { host, ssh: true },
None => GitRemote::Unknown(GIT_NO_HOST),
},
"http" | "https" | "git" => match destination_host(url) {
Some(host) => GitRemote::Host { host, ssh: false },
None => GitRemote::Unknown(GIT_NO_HOST),
},
_ => GitRemote::Unknown(GIT_SCHEME_UNKNOWN),
};
}
if let Some((before, _)) = url.split_once(':') {
let is_drive = before.len() == 1 && before.chars().all(|c| c.is_ascii_alphabetic());
if is_drive || before.contains('/') || before.contains('\\') {
return GitRemote::Local;
}
let host = before.rsplit('@').next().unwrap_or("").trim();
if host.is_empty() || host.contains('[') {
return GitRemote::Unknown(GIT_NO_HOST);
}
return GitRemote::Host {
host: host.to_ascii_lowercase(),
ssh: true,
};
}
GitRemote::Local
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitConfigEntry {
pub section: String,
pub subsection: Option<String>,
pub key: String,
pub value: String,
}
pub fn git_config_entries(text: &str) -> Result<Vec<GitConfigEntry>, &'static str> {
let mut out = Vec::new();
let mut current: Option<(String, Option<String>)> = None;
for raw in text.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
if let Some(rest) = line.strip_prefix('[') {
let end = rest.find(']').ok_or(GIT_CONFIG_UNPARSED)?;
let inner = &rest[..end];
let after = rest[end + 1..].trim();
if !after.is_empty() && !after.starts_with('#') && !after.starts_with(';') {
return Err(GIT_CONFIG_UNPARSED);
}
let header = git_config_header(inner)?;
if header.0 == "include" || header.0 == "includeif" {
return Err(GIT_CONFIG_INCLUDE);
}
current = Some(header);
continue;
}
let Some((section, subsection)) = current.clone() else {
return Err(GIT_CONFIG_UNPARSED);
};
let mut value = String::new();
let mut in_quote = false;
for c in line.chars() {
match c {
'"' => {
in_quote = !in_quote;
value.push(c);
}
'#' | ';' if !in_quote => break,
_ => value.push(c),
}
}
let value = value.trim();
let (key, val) = match value.split_once('=') {
Some((k, v)) => (k.trim(), v.trim()),
None => (value, "true"),
};
if key.is_empty() || !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
return Err(GIT_CONFIG_UNPARSED);
}
let names_a_destination = matches!(section.as_str(), "remote" | "url" | "branch");
if val.ends_with('\\') || (names_a_destination && val.contains('\\')) {
return Err(GIT_CONFIG_ESCAPE);
}
out.push(GitConfigEntry {
section,
subsection,
key: key.to_ascii_lowercase(),
value: val.replace('"', "").trim().to_string(),
});
}
Ok(out)
}
fn git_config_header(inner: &str) -> Result<(String, Option<String>), &'static str> {
if let Some(q) = inner.find('"') {
let name = inner[..q].trim().to_ascii_lowercase();
let sub = inner[q + 1..]
.strip_suffix('"')
.ok_or(GIT_CONFIG_UNPARSED)?;
if sub.contains('\\') {
return Err(GIT_CONFIG_ESCAPE);
}
if name.is_empty() || sub.contains('"') {
return Err(GIT_CONFIG_UNPARSED);
}
return Ok((name, Some(sub.to_string())));
}
let inner = inner.trim();
match inner.split_once('.') {
Some((name, sub)) => Ok((name.to_ascii_lowercase(), Some(sub.to_string()))),
None if !inner.is_empty() => Ok((inner.to_ascii_lowercase(), None)),
None => Err(GIT_CONFIG_UNPARSED),
}
}
fn git_head_branch(head: &str) -> Result<Option<String>, &'static str> {
let head = head.trim();
if let Some(r) = head.strip_prefix("ref: ") {
return Ok(r.trim().strip_prefix("refs/heads/").map(str::to_string));
}
if head.len() >= 40 && head.chars().all(|c| c.is_ascii_hexdigit()) {
return Ok(None);
}
Err(GIT_HEAD_UNREADABLE)
}
pub fn git_contact_urls(
lower: &[&str],
config: &str,
head: &str,
push: bool,
) -> Result<Vec<String>, &'static str> {
let mut lower_entries = Vec::new();
for text in lower {
lower_entries.extend(git_config_entries(text)?);
}
let repo_entries = git_config_entries(config)?;
let branch = git_head_branch(head)?;
let all = || repo_entries.iter().chain(lower_entries.iter());
let values = |section: &str, sub: Option<&str>, key: &str| -> Vec<String> {
let pick = |entries: &[GitConfigEntry]| -> Vec<String> {
entries
.iter()
.filter(|e| e.section == section && e.subsection.as_deref() == sub && e.key == key)
.map(|e| e.value.clone())
.collect()
};
let own = pick(&repo_entries);
if own.is_empty() {
pick(&lower_entries)
} else {
own
}
};
let mut remote: Option<String> = None;
if push {
if let Some(b) = &branch {
remote = values("branch", Some(b), "pushremote").pop();
}
if remote.is_none() {
remote = values("remote", None, "pushdefault").pop();
}
}
if remote.is_none() {
if let Some(b) = &branch {
remote = values("branch", Some(b), "remote").pop();
}
}
let remote = remote.unwrap_or_else(|| "origin".to_string());
if remote == "." {
return Ok(Vec::new());
}
if !all().any(|e| e.section == "remote" && e.subsection.as_deref() == Some(remote.as_str())) {
return Err(GIT_REMOTE_MISSING);
}
if all().any(|e| {
e.section == "remote" && e.subsection.as_deref() == Some(remote.as_str()) && e.key == "vcs"
}) {
return Err(GIT_REMOTE_VCS);
}
let mut urls = if push {
values("remote", Some(&remote), "pushurl")
} else {
Vec::new()
};
if urls.is_empty() {
urls = values("remote", Some(&remote), "url");
}
if urls.is_empty() {
return Err(GIT_REMOTE_NO_URL);
}
let rules = |key: &str| -> Vec<(String, String)> {
all()
.filter(|e| e.section == "url" && e.key == key)
.filter_map(|e| e.subsection.clone().map(|b| (b, e.value.clone())))
.filter(|(_, prefix)| !prefix.is_empty())
.collect()
};
let instead = rules("insteadof");
let push_instead = rules("pushinsteadof");
let rewrite = |url: &str, rules: &[(String, String)]| -> Option<String> {
rules
.iter()
.filter(|(_, p)| url.starts_with(p.as_str()))
.max_by_key(|(_, p)| p.len())
.map(|(base, p)| format!("{base}{}", &url[p.len()..]))
};
Ok(urls
.iter()
.map(|u| {
if push {
if let Some(r) = rewrite(u, &push_instead) {
return r;
}
}
rewrite(u, &instead).unwrap_or_else(|| u.clone())
})
.collect())
}
const GIT_OVERRIDE_VARS: &[&str] = &[
"GIT_DIR",
"GIT_CONFIG",
"GIT_CONFIG_GLOBAL",
"GIT_CONFIG_SYSTEM",
"GIT_CONFIG_PARAMETERS",
"GIT_CONFIG_COUNT",
];
pub fn check_git_remote_egress(builtin: &str, push: bool) -> Result<(), SafetyError> {
if policy_permissive() {
return Ok(());
}
let Some(allow) = net_allowlist() else {
return Ok(());
};
if allow.split([';', ',']).any(|e| e.trim() == "*") {
return Ok(());
}
let urls = match git_urls_in_working_repo(push) {
Ok(urls) => urls,
Err(reason) => return check_egress_unknown(builtin, reason),
};
let mut targets = Vec::new();
for url in urls {
match git_remote_target(&url) {
GitRemote::Local => {}
GitRemote::Host { host, ssh } => {
let host = if ssh {
ssh_egress_host(builtin, &host)?
} else {
host
};
targets.push(format!("https://{host}"));
}
GitRemote::Unknown(reason) => return check_egress_unknown(builtin, reason),
}
}
if targets.is_empty() {
return Ok(());
}
check_egress(builtin, &targets)
}
fn git_urls_in_working_repo(push: bool) -> Result<Vec<String>, &'static str> {
if GIT_OVERRIDE_VARS
.iter()
.any(|v| std::env::var_os(v).is_some())
{
return Err(GIT_ENV_OVERRIDE);
}
let cwd = std::env::current_dir().map_err(|_| GIT_NO_REPO)?;
let dot_git = cwd
.ancestors()
.map(|d| d.join(".git"))
.find(|p| p.exists())
.ok_or(GIT_NO_REPO)?;
let (git_dir, common_dir) = git_dirs_of(&dot_git)?;
let config = read_git_file(&common_dir.join("config")).ok_or(GIT_CONFIG_UNREADABLE)?;
let head = read_git_file(&git_dir.join("HEAD")).ok_or(GIT_HEAD_UNREADABLE)?;
let layers = git_config_layers()?;
let lower: Vec<&str> = layers.iter().map(String::as_str).collect();
git_contact_urls(&lower, &config, &head, push)
}
fn git_dirs_of(
dot_git: &std::path::Path,
) -> Result<(std::path::PathBuf, std::path::PathBuf), &'static str> {
if dot_git.is_dir() {
let common = common_dir_of(dot_git)?;
return Ok((dot_git.to_path_buf(), common));
}
if !dot_git.is_file() {
return Err(GIT_NO_REPO);
}
let text = read_git_file(dot_git).ok_or(GIT_POINTER_MALFORMED)?;
let target = text
.lines()
.next()
.and_then(|l| l.trim().strip_prefix("gitdir:"))
.map(str::trim)
.filter(|t| !t.is_empty() && !names_a_remote_share(t))
.ok_or(GIT_POINTER_MALFORMED)?;
let base = dot_git.parent().ok_or(GIT_POINTER_MALFORMED)?;
let git_dir = base.join(target);
if !git_dir.is_dir() {
return Err(GIT_POINTER_MALFORMED);
}
let common = common_dir_of(&git_dir)?;
Ok((git_dir, common))
}
fn common_dir_of(git_dir: &std::path::Path) -> Result<std::path::PathBuf, &'static str> {
let file = git_dir.join("commondir");
if !file.try_exists().map_err(|_| GIT_POINTER_MALFORMED)? {
return Ok(git_dir.to_path_buf());
}
let text = read_git_file(&file).ok_or(GIT_POINTER_MALFORMED)?;
let rel = text.lines().next().unwrap_or("").trim();
if rel.is_empty() || names_a_remote_share(rel) {
return Err(GIT_POINTER_MALFORMED);
}
let common = git_dir.join(rel);
if !common.is_dir() {
return Err(GIT_POINTER_MALFORMED);
}
Ok(common)
}
fn git_config_layers() -> Result<Vec<String>, &'static str> {
read_config_layers(&git_config_paths(|name| std::env::var_os(name)))
}
pub fn git_config_paths(
var: impl Fn(&str) -> Option<std::ffi::OsString>,
) -> Vec<std::path::PathBuf> {
use std::path::PathBuf;
let set = |name: &str| var(name).filter(|v| !v.is_empty());
let truthy = |name: &str| {
set(name).is_some_and(|v| {
let v = v.to_string_lossy().trim().to_ascii_lowercase();
!matches!(v.as_str(), "0" | "false" | "no" | "off")
})
};
let mut paths: Vec<PathBuf> = Vec::new();
if !truthy("GIT_CONFIG_NOSYSTEM") {
paths.push(PathBuf::from("/etc/gitconfig"));
if let Some(pd) = set("ProgramData") {
paths.push(PathBuf::from(pd).join("Git").join("config"));
}
if let Some(install) = set("PATH").and_then(|p| git_install_dir(&p)) {
paths.push(install.join("etc").join("gitconfig"));
paths.push(install.join("mingw64").join("etc").join("gitconfig"));
}
}
let home = set("HOME")
.or_else(|| set("USERPROFILE"))
.map(PathBuf::from);
match set("XDG_CONFIG_HOME") {
Some(xdg) => paths.push(PathBuf::from(xdg).join("git").join("config")),
None => {
if let Some(h) = &home {
paths.push(h.join(".config").join("git").join("config"));
}
}
}
if let Some(h) = &home {
paths.push(h.join(".gitconfig"));
}
paths
}
pub fn git_install_dir_of(git_exe: &str) -> Option<String> {
let parts: Vec<&str> = git_exe.split(['\\', '/']).collect();
let (name, dirs) = parts.split_last()?;
if !name.eq_ignore_ascii_case("git.exe") && !name.eq_ignore_ascii_case("git") {
return None;
}
let last = dirs.last()?.to_ascii_lowercase();
let keep = match last.as_str() {
"cmd" => dirs.len() - 1,
"bin" if dirs.len() >= 2 && dirs[dirs.len() - 2].eq_ignore_ascii_case("mingw64") => {
dirs.len() - 2
}
"bin" => dirs.len() - 1,
_ => return None,
};
if keep == 0 {
return None;
}
Some(dirs[..keep].join("\\"))
}
fn git_install_dir(path_var: &std::ffi::OsStr) -> Option<std::path::PathBuf> {
if !cfg!(windows) {
return None;
}
let git = std::env::split_paths(path_var)
.filter(|dir| !names_a_remote_share(&dir.to_string_lossy()))
.map(|dir| dir.join("git.exe"))
.find(|p| p.is_file())?;
git_install_dir_of(&git.to_string_lossy()).map(std::path::PathBuf::from)
}
fn read_config_layers(paths: &[std::path::PathBuf]) -> Result<Vec<String>, &'static str> {
let mut out = Vec::new();
for path in paths {
if names_a_remote_share(&path.to_string_lossy()) {
return Err(GIT_CONFIG_UNREADABLE);
}
match path.try_exists() {
Ok(false) => continue,
Ok(true) => {}
Err(_) => return Err(GIT_CONFIG_UNREADABLE),
}
out.push(read_git_file(path).ok_or(GIT_CONFIG_UNREADABLE)?);
}
Ok(out)
}
fn read_git_file(path: &std::path::Path) -> Option<String> {
use std::io::Read;
let file = std::fs::File::open(path).ok()?;
let mut buf = Vec::new();
file.take(SSH_CONFIG_MAX_BYTES + 1)
.read_to_end(&mut buf)
.ok()?;
if buf.len() as u64 > SSH_CONFIG_MAX_BYTES {
return None;
}
Some(String::from_utf8_lossy(&buf).into_owned())
}
pub fn guard_redirect(
builtin: &str,
from: &str,
to: &str,
) -> anyhow::Result<crate::security::CheckedHttpUrl> {
let scheme = url::Url::parse(to).map(|u| u.scheme().to_string());
if !matches!(scheme.as_deref(), Ok("http" | "https")) || to.chars().any(char::is_control) {
let _ = audit(
builtin,
Effect::Network,
"deny_redirect_scheme",
to,
json!({ "from": from }),
);
return Err(policy_deny(
builtin,
format!("{builtin}: {from} redirected to {to:?}, which is not an http or https URL"),
"only http and https redirects are followed; a file: or other target would reach past the network guards",
));
}
if !policy_permissive() {
if let Some(allow) = net_allowlist() {
if let Err(detail) = egress_permitted(&[to.to_string()], &allow) {
let _ = audit(
builtin,
Effect::Network,
"deny_egress_redirect",
to,
json!({ "from": from, "allow": allow, "detail": detail }),
);
return Err(policy_deny(
builtin,
format!("{builtin}: redirect to {to} denied: {detail}"),
format!("IRONSHELL_NET_ALLOW permits only: {allow}; the server redirected outside it"),
));
}
}
}
match crate::security::check_http_url(builtin, to) {
Ok(checked) => Ok(checked),
Err(e) => {
let _ = audit(
builtin,
Effect::Network,
"deny_redirect_ssrf",
to,
json!({ "from": from, "detail": e.to_string() }),
);
Err(policy_deny(
builtin,
format!("{builtin}: {from} redirected to {to}, which is refused: {e}"),
"a public server may not redirect a request onto this machine or a private network",
))
}
}
}
pub fn reserved_device_name(path: &str) -> Option<String> {
for part in path.split(['/', '\\']) {
let stem = part.split('.').next().unwrap_or("").trim_end_matches(' ');
let up = stem.to_ascii_uppercase();
let device = matches!(
up.as_str(),
"CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$"
) || (up.len() == 4
&& (up.starts_with("COM") || up.starts_with("LPT"))
&& up.as_bytes()[3].is_ascii_digit()
&& up.as_bytes()[3] != b'0');
if device {
return Some(stem.to_string());
}
}
None
}
pub fn reserved_device_error(builtin: &str, name: &str) -> SafetyError {
SafetyError {
code: ErrorCode::BadArg,
message: format!("{builtin}: `{name}` is a reserved device name on Windows"),
builtin: builtin.to_string(),
hint: "Windows treats CON, PRN, AUX, NUL, COM1-COM9 and LPT1-LPT9 (with or without an extension) as devices, and a file with such a name cannot be removed; choose another name".to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: "a name that is not a Windows device name".to_string(),
got: name.to_string(),
extra: serde_json::Map::new(),
}
}
pub fn windows_name_refusal(builtin: &str, path: &str) -> Option<SafetyError> {
if let Some(name) = reserved_device_name(path) {
return Some(reserved_device_error(builtin, &name));
}
let mut rest = path;
for prefix in ["\\\\?\\", "\\\\.\\", "//?/", "//./"] {
if let Some(r) = rest.strip_prefix(prefix) {
rest = r;
break;
}
}
let bytes = rest.as_bytes();
if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
rest = &rest[2..];
}
for part in rest.split(['/', '\\']) {
if part.is_empty() || part == "." || part == ".." {
continue;
}
if part.contains(':') {
return Some(SafetyError {
code: ErrorCode::BadArg,
message: format!(
"{builtin}: `{part}` names an alternate data stream on Windows (a `:` after the drive)"
),
builtin: builtin.to_string(),
hint: "Windows reads `name:stream` as a hidden stream of the file `name`, not as a file called that; choose a name without a colon".to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: "a name without a `:`".to_string(),
got: part.to_string(),
extra: serde_json::Map::new(),
});
}
if part.ends_with('.') || part.ends_with(' ') {
return Some(SafetyError {
code: ErrorCode::BadArg,
message: format!(
"{builtin}: `{part}` ends in a dot or a space, which Windows would strip, so the write would land on a different name"
),
builtin: builtin.to_string(),
hint: "Windows silently drops a trailing dot or space from a name, so `a.txt.` writes `a.txt`; remove it from the name".to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: "a name that does not end in a dot or a space".to_string(),
got: part.to_string(),
extra: serde_json::Map::new(),
});
}
}
None
}
pub fn guard(ctx: GuardCtx) -> Result<(), SafetyError> {
let mode = current_mode();
let resource = ctx.targets.join(", ");
if cfg!(windows) && ctx.effect.is_filesystem() && ctx.fs_paths {
for t in &ctx.targets {
let refusal = if matches!(ctx.effect, Effect::WriteLocal | Effect::Destructive) {
windows_name_refusal(ctx.builtin, t)
} else {
reserved_device_name(t).map(|name| reserved_device_error(ctx.builtin, &name))
};
if let Some(e) = refusal {
return Err(e);
}
}
}
if let Err(e) = governor_admit(ctx.effect, ctx.builtin) {
let _ = audit(
ctx.builtin,
ctx.effect,
"deny_budget",
&resource,
json!({ "governor": governor_snapshot() }),
);
return Err(e);
}
if jail_enforced(mode) && ctx.effect.is_filesystem() && ctx.fs_paths {
for t in &ctx.targets {
if !within_workspace(t) {
let _ = audit(
ctx.builtin,
ctx.effect,
"deny_outside_workspace",
&resource,
json!({ "target": t }),
);
return Err(SafetyError {
code: ErrorCode::OutsideWorkspace,
message: format!("{}: '{}' is outside the workspace root", ctx.builtin, t),
builtin: ctx.builtin.to_string(),
hint: outside_workspace_hint(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
});
}
if is_audit_artifact(t) {
let _ = audit(
ctx.builtin,
ctx.effect,
"deny_audit_tamper",
&resource,
json!({ "target": t }),
);
return Err(SafetyError {
code: ErrorCode::PolicyDeny,
message: format!(
"{}: '{}' is the audit log or its directory, which is protected and cannot be written by a guarded builtin",
ctx.builtin, t
),
builtin: ctx.builtin.to_string(),
hint: "the audit trail is evidence about this session, so the session \
may not edit it; point IRONSHELL_AUDIT_LOG elsewhere if you need a \
different location"
.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
});
}
if is_governance_file(t) {
let _ = audit(
ctx.builtin,
ctx.effect,
"deny_governance_tamper",
&resource,
json!({ "target": t }),
);
return Err(SafetyError {
code: ErrorCode::OutsideWorkspace,
message: format!(
"{}: '{}' configures this shell's access control or audit key and cannot be written by a guarded builtin",
ctx.builtin, t
),
builtin: ctx.builtin.to_string(),
hint: "the RBAC config and the audit key are read at startup and govern \
later calls, so the session may not edit them; set \
IRONSHELL_RBAC_CONFIG or IRONSHELL_AUDIT_KEY_FILE to a path outside the \
workspace"
.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
});
}
if mode == Mode::Agent && is_git_control_path(t) {
let _ = audit(
ctx.builtin,
ctx.effect,
"deny_git_control",
&resource,
json!({ "target": t }),
);
return Err(SafetyError {
code: ErrorCode::OutsideWorkspace,
message: format!(
"{}: '{}' is inside a .git directory and cannot be written by an agent",
ctx.builtin, t
),
builtin: ctx.builtin.to_string(),
hint: "git runs programs named in .git/config and .git/hooks, so a write \
there would run code on the next git call; use the git_* builtins \
to change a repository"
.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
});
}
}
}
if ctx.effect == Effect::Network
&& !policy_permissive()
&& !EGRESS_JUDGED_BY_BODY.contains(&ctx.builtin)
{
if let Some(allow) = net_allowlist() {
if let Err(detail) = egress_permitted(&ctx.targets, &allow) {
return Err(egress_denied(
ctx.builtin,
ctx.effect,
&resource,
&allow,
detail,
));
}
}
}
if let Some(true) = rbac_authorized(ctx.effect, ctx.builtin) {
let _ = audit(
ctx.builtin,
ctx.effect,
"rbac_allow",
&resource,
json!({ "principal": current_principal() }),
);
return Ok(());
}
let decision = match ctx.builtin {
"exit" => Decision::Allow,
_ => effective_decision(ctx.effect, mode),
};
match decision {
Decision::Allow => {
let _ = audit(ctx.builtin, ctx.effect, "allow", &resource, json!({}));
Ok(())
}
Decision::Deny => {
let _ = audit(ctx.builtin, ctx.effect, "deny", &resource, json!({}));
Err(SafetyError {
code: ErrorCode::PolicyDeny,
message: format!(
"{}: {} operations are denied by policy in agent mode",
ctx.builtin,
ctx.effect.as_str()
),
builtin: ctx.builtin.to_string(),
hint: "this effect class has no approval path; perform it outside agent mode"
.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
})
}
Decision::Approve => {
let descriptor = ApprovalDescriptor::new(
ctx.what,
ctx.builtin,
ctx.targets.clone(),
ctx.blast_radius.clone(),
ctx.reversible,
)
.map_err(|e| ic_refusal(ctx.builtin, e))?;
if is_token_approved(&descriptor.token) {
let _ = audit(
ctx.builtin,
ctx.effect,
"approved",
&resource,
json!({ "token": descriptor.token }),
);
Ok(())
} else {
let _ = audit(
ctx.builtin,
ctx.effect,
"needs_approval",
&resource,
json!({ "token": descriptor.token }),
);
let token = descriptor.token.clone();
let hint = approval_refusal_hint(&token, ctx.effect == Effect::Network);
Err(SafetyError {
code: ErrorCode::NeedsApproval,
message: format!("{}: requires approval ({})", ctx.builtin, ctx.what),
builtin: ctx.builtin.to_string(),
hint,
approval: Some(descriptor),
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
})
}
}
}
}
pub const SELF_GUARDED: &[&str] = &[
"age_decrypt",
"age_encrypt",
"age_keygen",
"agent_with_mcp",
"apply",
"at_remove",
"at_schedule",
"bat_view",
"bzip2_compress",
"bzip2_decompress",
"cd",
"cloud_instance_destroy",
"code_format",
"cron_add",
"cron_disable",
"cron_enable",
"cron_remove",
"db_csv_to_sqlite",
"db_kv_delete",
"db_sqlite_backup",
"db_sqlite_create",
"db_sqlite_delete",
"db_sqlite_drop_table",
"db_sqlite_to_csv",
"docker_compose_down",
"docker_exec",
"docker_rm",
"file_append",
"file_backup",
"file_copy",
"file_delete_lines",
"file_diff",
"file_edit",
"file_insert",
"file_mkdir",
"file_move",
"file_patch",
"file_write",
"fs_chmod",
"fs_chown",
"fs_link",
"fs_symlink",
"git_ignore",
"gpg_decrypt",
"gpg_encrypt",
"gui_ocr",
"gui_screenshot",
"gui_screenshot_window",
"gzip_compress",
"gzip_decompress",
"http_get",
"json_set",
"k8s_delete",
"k8s_exec",
"ltrace_cmd",
"marketplace_info",
"marketplace_search",
"marketplace_update",
"nc_listen",
"net_close",
"net_connect",
"net_listen",
"net_recv",
"net_send",
"openssl_genrsa",
"pager",
"perf_record",
"perf_stat",
"perm_set",
"platform_db_delete",
"platform_db_export",
"podman_exec",
"podman_rm",
"proc_kill",
"proc_spawn",
"project_scripts",
"rbac_principal",
"refactor_rename_file",
"repl_connect",
"repl_serve",
"rlm_spawn",
"rm",
"rmdir",
"save_json",
"scp_download",
"sed_replace",
"session_export",
"sh",
"ssh_exec",
"ssh_keygen",
"strace_cmd",
"tar_create",
"tar_extract",
"tee_output",
"terraform_destroy",
"timeout_cmd",
"tmux_new",
"tmux_send",
"tool_exec",
"touch",
"web_check_url",
"web_cookies",
"web_download",
"web_fetch",
"web_form_submit",
"web_graphql",
"web_headers",
"web_json_get",
"web_json_post",
"web_open_url",
"web_post",
"web_rest_api",
"web_scrape",
"web_upload_file",
"wget_download",
"xargs_exec",
"xz_compress",
"xz_decompress",
"zip_add",
"zip_create",
"zip_extract",
"zstd_compress",
"zstd_decompress",
];
fn centrally_enforced(effect: Effect) -> bool {
matches!(
effect,
Effect::Process
| Effect::Destructive
| Effect::Exec
| Effect::Privileged
| Effect::Network
)
}
const TARGET_PARAMS: &[&str] = &[
"path",
"file",
"filename",
"file_path",
"source",
"destination",
"dest",
"target",
"url",
"host",
"hostname",
];
fn target_resource(builtin: &str, args: &[crate::value::Value]) -> String {
let named: Vec<&str> = crate::signature::signature_of(builtin)
.map(|sig| {
sig.params
.iter()
.enumerate()
.filter(|(_, p)| !p.variadic && TARGET_PARAMS.contains(&p.name))
.filter_map(|(i, _)| match args.get(i) {
Some(crate::value::Value::Str(s)) => Some(s.as_str()),
_ => None,
})
.collect()
})
.unwrap_or_default();
let rest = args.len() - named.len();
match (named.is_empty(), rest) {
(true, 0) => String::new(),
(true, n) => format!("<{n} arguments>"),
(false, 0) => named.join(", "),
(false, n) => format!("{}, <{n} more arguments>", named.join(", ")),
}
}
pub fn existing_paths(args: &[String]) -> Vec<String> {
args.iter()
.filter(|s| {
!s.is_empty()
&& s.len() <= 4096
&& !s.contains('\n')
&& !names_a_remote_share(s)
&& std::path::Path::new(s).exists()
})
.cloned()
.collect()
}
pub fn names_a_remote_share(s: &str) -> bool {
let b = s.as_bytes();
cfg!(windows) && b.len() >= 2 && matches!(b[0], b'/' | b'\\') && matches!(b[1], b'/' | b'\\')
}
const CREDENTIAL_PARAMS: &[&str] = &["password", "private_key", "token"];
fn without_credentials(
builtin: &str,
args: &[crate::value::Value],
) -> Option<Vec<crate::value::Value>> {
let sig = crate::signature::signature_of(builtin)?;
let mut out: Option<Vec<crate::value::Value>> = None;
for (i, p) in sig.params.iter().enumerate() {
if i < args.len() && CREDENTIAL_PARAMS.contains(&p.name) {
out.get_or_insert_with(|| args.to_vec())[i] =
crate::value::Value::Str(REDACTION_MARKER.to_string());
}
}
out
}
lazy_static! {
static ref GUARDED_UNDER: std::collections::HashMap<&'static str, &'static str> = {
let mut map = std::collections::HashMap::new();
for group in crate::builtins::alias_groups() {
let Some(canonical) = group.iter().copied().find(|n| SELF_GUARDED.contains(n)) else {
continue;
};
for name in group {
if !SELF_GUARDED.contains(&name) {
map.insert(name, canonical);
}
}
}
map
};
}
pub fn guard_dispatch(builtin: &str, args: &[crate::value::Value]) -> Result<(), SafetyError> {
let builtin = GUARDED_UNDER.get(builtin).copied().unwrap_or(builtin);
let effect = effect_of(builtin);
if matches!(effect, Effect::Pure | Effect::ReadLocal) {
return Ok(());
}
let redacted = without_credentials(builtin, args);
let args = redacted.as_deref().unwrap_or(args);
let args_as_strings: Vec<String> = args
.iter()
.filter_map(|a| match a {
crate::value::Value::Str(s) => Some(s.clone()),
_ => None,
})
.collect();
if SELF_GUARDED.contains(&builtin) {
return Ok(());
}
if !centrally_enforced(effect) {
if matches!(effect, Effect::WriteLocal | Effect::Network) && current_mode() == Mode::Agent {
let _ = audit(
builtin,
effect,
"allow_unguarded",
&target_resource(builtin, args),
json!({ "central": true }),
);
}
return Ok(());
}
let real_paths = existing_paths(&args_as_strings);
let jailable = effect.is_filesystem() && !real_paths.is_empty();
let targets = if jailable {
real_paths
} else {
args_as_strings
};
let argv = serde_json::to_value(args).unwrap_or(Json::Null);
let mut blast_radius = json!({ "args": argv });
if builtin == "ai" {
if let Some(destination) = crate::ai::ai_gate_destination() {
blast_radius["destination"] = json!(destination);
}
}
guard(GuardCtx {
builtin,
effect,
what: effect.as_str(),
targets,
blast_radius,
reversible: false,
fs_paths: jailable,
})
}
pub const REDACTION_MARKER: &str = "[REDACTED]";
lazy_static! {
static ref SECRET_TOKEN_RE: regex::Regex = regex::Regex::new(concat!(
r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----",
r"|eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}", r"|AKIA[0-9A-Z]{16}", r"|sk-(?:ant-)?[A-Za-z0-9_-]{16,}", r"|gh[pousr]_[A-Za-z0-9]{20,}", r"|xox[baprs]-[A-Za-z0-9-]{10,}", r"|AIza[0-9A-Za-z_-]{20,}", r"|[rs]k_(?:live|test)_[A-Za-z0-9]{16,}", r"|\bhf_[A-Za-z0-9]{30,}", r"|github_pat_[A-Za-z0-9_]{22,}", r"|glpat-[A-Za-z0-9_-]{20,}", )).unwrap();
static ref URL_QUERY_SECRET_RE: regex::Regex = regex::Regex::new(
r#"(?i)([?&](?:api[_-]?key|apikey|access[_-]?token|access[_-]?key|token|key|secret|password|passwd|pwd)=)([^&\s#"'<>]{6,})"#
).unwrap();
static ref URL_CRED_RE: regex::Regex =
regex::Regex::new(r"([a-zA-Z][a-zA-Z0-9+.\-]*://[^/\s:@]+:)([^/\s:@]+)(@)").unwrap();
static ref SECRET_ASSIGN_RE: regex::Regex = regex::Regex::new(
r#"(?i)((?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|client[_-]?secret|auth[_-]?token)["']?\s*[:=]\s*["']?)((?:[^\s"',}\\]|\\[^nrtbfu"\\/\s]){6,})"#
).unwrap();
}
pub fn redaction_enabled() -> bool {
!matches!(
std::env::var("IRONSHELL_REDACT").ok().as_deref(),
Some("off") | Some("0") | Some("false") | Some("no")
)
}
pub fn secrets_permitted() -> bool {
matches!(
std::env::var("IRONSHELL_SECRETS").ok().as_deref(),
Some("allow") | Some("1") | Some("true")
)
}
pub fn is_secret_name(name: &str) -> bool {
let n = name.to_ascii_uppercase();
const NEEDLES: &[&str] = &[
"SECRET",
"TOKEN",
"PASSWORD",
"PASSWD",
"PASSPHRASE",
"_KEY",
"APIKEY",
"API_KEY",
"ACCESS_KEY",
"PRIVATE_KEY",
"CREDENTIAL",
"CLIENT_SECRET",
"AUTH_TOKEN",
"SESSION_KEY",
];
const SUFFIXES: &[&str] = &["_PAT", "_CREDS", "_CREDENTIALS", "_AUTH"];
NEEDLES.iter().any(|needle| n.contains(needle)) || SUFFIXES.iter().any(|s| n.ends_with(s))
}
pub fn env_secret_gated(name: &str) -> bool {
current_mode() == Mode::Agent
&& redaction_enabled()
&& !secrets_permitted()
&& is_secret_name(name)
}
pub(crate) fn env_name_steers_children(name: &str) -> bool {
const EXACT: &[&str] = &[
"PATH",
"PATHEXT",
"HOME",
"USERPROFILE",
"HOMEDRIVE",
"HOMEPATH",
"APPDATA",
"LOCALAPPDATA",
"COMSPEC",
"SYSTEMROOT",
"IFS",
"BASH_ENV",
"ENV",
"ZDOTDIR",
"SHELL",
"PAGER",
"EDITOR",
"VISUAL",
"BROWSER",
"PYTHONPATH",
"PYTHONSTARTUP",
"PYTHONHOME",
"NODE_OPTIONS",
"NODE_PATH",
"PERL5LIB",
"PERL5OPT",
"RUBYOPT",
"RUBYLIB",
"CURL_HOME",
"WGETRC",
"SSH_ASKPASS",
"SUDO_ASKPASS",
"SSH_AUTH_SOCK",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"REQUESTS_CA_BUNDLE",
"NODE_EXTRA_CA_CERTS",
"DOCKER_HOST",
"DOCKER_CONFIG",
"KUBECONFIG",
"GNUPGHOME",
"MANPAGER",
"GIT",
];
const PREFIXES: &[&str] = &[
"IRONSHELL_",
"IRONGATE_",
"GIT_",
"LD_",
"DYLD_",
"XDG_",
"CARGO_",
"RUSTC_",
"NPM_CONFIG_",
"PIP_",
];
const SUFFIXES: &[&str] = &[
"_PROXY",
"_API_KEY",
"_TOKEN",
"_BASE_URL",
"_API_BASE",
"_ENDPOINT",
];
let upper = name.to_ascii_uppercase();
EXACT.contains(&upper.as_str())
|| PREFIXES.iter().any(|p| upper.starts_with(p))
|| SUFFIXES.iter().any(|s| upper.ends_with(s))
}
pub(crate) fn env_change_refused(name: &str) -> bool {
current_mode() == Mode::Agent && env_name_steers_children(name)
}
pub fn clipboard_read_gate(builtin: &str) -> anyhow::Result<()> {
if current_mode() == Mode::Agent && !policy_permissive() && !secrets_permitted() {
return Err(policy_deny(
builtin,
"agent mode does not read the clipboard, which often holds a password \
that redaction cannot recognise; NOTHING WAS READ",
"set IRONSHELL_SECRETS=allow to permit it, or pass the text as an argument",
));
}
Ok(())
}
pub fn redact_str(s: &str) -> (String, bool) {
let mut cur = s.to_string();
let mut changed = false;
if SECRET_TOKEN_RE.is_match(&cur) {
cur = SECRET_TOKEN_RE
.replace_all(&cur, REDACTION_MARKER)
.into_owned();
changed = true;
}
if URL_CRED_RE.is_match(&cur) {
cur = URL_CRED_RE
.replace_all(&cur, concat!("${1}", "[REDACTED]", "${3}"))
.into_owned();
changed = true;
}
if SECRET_ASSIGN_RE.is_match(&cur) {
let mut replaced = false;
cur = SECRET_ASSIGN_RE
.replace_all(&cur, |caps: ®ex::Captures| {
let value = &caps[2];
let word = value.trim_end_matches(')');
if crate::signature::Ty::is_type_name(word) {
return caps[0].to_string();
}
if is_capability_token(word) {
return caps[0].to_string();
}
if names_secret_variable(word) {
return caps[0].to_string();
}
replaced = true;
format!("{}[REDACTED]{}", &caps[1], &value[word.len()..])
})
.into_owned();
changed |= replaced;
}
if URL_QUERY_SECRET_RE.is_match(&cur) {
cur = URL_QUERY_SECRET_RE
.replace_all(&cur, concat!("${1}", "[REDACTED]"))
.into_owned();
changed = true;
}
(cur, changed)
}
fn names_secret_variable(word: &str) -> bool {
let name = word.strip_prefix('$').unwrap_or(word);
name.contains('_')
&& name.starts_with(|c: char| c.is_ascii_uppercase())
&& name
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
&& is_secret_name(name)
}
pub fn is_capability_token(s: &str) -> bool {
s.starts_with("apv_") || s.starts_with("apl_")
}
pub fn redact_json(v: &mut Json) {
match v {
Json::String(s) => {
let (r, changed) = redact_str(s);
if changed {
*s = r;
}
}
Json::Array(items) => {
for it in items.iter_mut() {
redact_json(it);
}
}
Json::Object(map) => {
for (k, val) in map.iter_mut() {
let hide =
is_secret_name(k) && matches!(val, Json::String(s) if !is_capability_token(s));
if hide {
*val = Json::String(REDACTION_MARKER.to_string());
} else {
redact_json(val);
}
}
}
_ => {}
}
}
thread_local! {
static DEADLINE: std::cell::Cell<Option<std::time::Instant>> =
const { std::cell::Cell::new(None) };
static STEPS: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
}
static CLI_TIMEOUT_SECS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
pub fn enter_cli_deadline(secs: u64) -> DeadlineGuard {
CLI_TIMEOUT_SECS.store(secs, std::sync::atomic::Ordering::Relaxed);
enter_deadline(std::time::Duration::from_secs(secs))
}
const DEADLINE_CHECK_INTERVAL: u32 = 1024;
pub struct DeadlineGuard {
previous: Option<std::time::Instant>,
}
impl Drop for DeadlineGuard {
fn drop(&mut self) {
DEADLINE.with(|d| d.set(self.previous));
STEPS.with(|s| s.set(0));
}
}
pub fn enter_deadline(limit: std::time::Duration) -> DeadlineGuard {
let previous = DEADLINE.with(|d| d.get());
DEADLINE.with(|d| d.set(Some(std::time::Instant::now() + limit)));
STEPS.with(|s| s.set(0));
DeadlineGuard { previous }
}
#[cfg(feature = "native")]
pub(crate) fn bounded_io_timeout(
builtin: &str,
requested: std::time::Duration,
) -> anyhow::Result<std::time::Duration> {
DEADLINE.with(|d| match d.get() {
None => Ok(requested),
Some(at) => {
let remaining = at.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
Err(budget_exceeded(
builtin,
"evaluation deadline expired before socket I/O".into(),
"use a sufficient evaluation time limit",
))
} else {
Ok(requested.min(remaining))
}
}
})
}
pub(crate) fn remaining_deadline() -> Option<std::time::Duration> {
DEADLINE.with(|d| {
d.get()
.map(|at| at.saturating_duration_since(std::time::Instant::now()))
})
}
#[inline]
pub fn check_deadline() -> anyhow::Result<()> {
crate::memory::check()?;
let n = STEPS.with(|s| {
let n = s.get().wrapping_add(1);
s.set(n);
n
});
if !n.is_multiple_of(DEADLINE_CHECK_INTERVAL) {
return Ok(());
}
DEADLINE.with(|d| match d.get() {
Some(at) if std::time::Instant::now() >= at => Err(budget_exceeded(
"eval",
match CLI_TIMEOUT_SECS.load(std::sync::atomic::Ordering::Relaxed) {
0 => "evaluation exceeded its time limit and was cancelled".to_string(),
n => format!("evaluation exceeded the {n} s deadline"),
},
"make the evaluation shorter, or give it a longer time limit (--timeout SECONDS)",
)),
_ => Ok(()),
})
}
pub const EVAL_STACK_SIZE: usize = 256 * 1024 * 1024;
pub const MAX_CALL_DEPTH: u32 = 2_000;
thread_local! {
static CALL_DEPTH: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
}
pub struct CallDepthGuard;
impl Drop for CallDepthGuard {
fn drop(&mut self) {
CALL_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
}
}
pub fn enter_call() -> anyhow::Result<CallDepthGuard> {
let depth = CALL_DEPTH.with(|d| {
let n = d.get() + 1;
d.set(n);
n
});
if depth > MAX_CALL_DEPTH {
CALL_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
return Err(budget_exceeded(
"call",
format!(
"recursion too deep (limit {MAX_CALL_DEPTH}) — a function is most \
likely calling itself without a base case"
),
&format!(
"calls nest at most {MAX_CALL_DEPTH} deep; if the recursion is intended, \
iterate instead with reduce over arr.range(n), or check the base case"
),
));
}
Ok(CallDepthGuard)
}
pub fn current_call_depth() -> u32 {
CALL_DEPTH.with(|d| d.get())
}
pub fn with_eval_stack<T, F>(f: F) -> T
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
{
let handle = std::thread::Builder::new()
.name("ironshell-eval".into())
.stack_size(EVAL_STACK_SIZE)
.spawn(f)
.expect("failed to spawn the evaluation thread");
match handle.join() {
Ok(v) => v,
Err(panic) => std::panic::resume_unwind(panic),
}
}
#[cfg(test)]
mod not_found_tests {
use super::*;
#[test]
fn the_hint_names_the_kind_in_the_plural() {
for (kind, plural) in [
("process", "processes"),
("job", "jobs"),
("log file", "log files"),
] {
let e = not_found("x", kind, "1");
let hint = &e.downcast_ref::<SafetyError>().unwrap().hint;
assert!(hint.contains(&format!("available {plural} and")), "{hint}");
}
}
}
#[cfg(test)]
mod depth_tests {
use super::*;
#[test]
fn depth_is_released_when_the_guard_drops() {
let before = current_call_depth();
{
let _g = enter_call().expect("first level must be allowed");
assert_eq!(current_call_depth(), before + 1);
}
assert_eq!(
current_call_depth(),
before,
"depth must unwind, or a long-lived thread would drift to the limit"
);
}
#[test]
fn exceeding_the_limit_is_an_error_not_a_crash() {
let mut guards = Vec::new();
let mut refused = false;
for _ in 0..(MAX_CALL_DEPTH + 10) {
match enter_call() {
Ok(g) => guards.push(g),
Err(e) => {
assert!(e.to_string().contains("recursion too deep"), "got {e}");
refused = true;
break;
}
}
}
assert!(refused, "the limit must be enforced");
drop(guards);
assert_eq!(current_call_depth(), 0, "depth must return to zero");
}
#[test]
fn a_refused_call_does_not_leak_depth() {
let mut guards = Vec::new();
while let Ok(g) = enter_call() {
guards.push(g);
}
let at_limit = current_call_depth();
for _ in 0..5 {
assert!(enter_call().is_err());
}
assert_eq!(
current_call_depth(),
at_limit,
"a rejected call must leave the depth unchanged"
);
}
}
#[cfg(test)]
mod deadline_tests {
use super::*;
use std::time::Duration;
#[test]
fn no_deadline_means_no_interruption() {
for _ in 0..(DEADLINE_CHECK_INTERVAL * 4) {
check_deadline().expect("must not fail when no deadline is set");
}
}
#[test]
fn an_expired_deadline_is_reported() {
let _g = enter_deadline(Duration::from_millis(1));
std::thread::sleep(Duration::from_millis(20));
let mut hit = false;
for _ in 0..(DEADLINE_CHECK_INTERVAL * 2) {
if check_deadline().is_err() {
hit = true;
break;
}
}
assert!(hit, "an expired deadline must eventually stop evaluation");
}
#[test]
fn a_live_deadline_does_not_fire() {
let _g = enter_deadline(Duration::from_secs(60));
for _ in 0..(DEADLINE_CHECK_INTERVAL * 4) {
check_deadline().expect("a deadline far in the future must not fire");
}
}
#[test]
fn the_guard_restores_state_so_pooled_threads_are_not_poisoned() {
{
let _g = enter_deadline(Duration::from_millis(1));
std::thread::sleep(Duration::from_millis(20));
}
for _ in 0..(DEADLINE_CHECK_INTERVAL * 4) {
check_deadline().expect("deadline must not outlive its guard");
}
}
#[test]
fn nesting_restores_the_outer_deadline() {
let _outer = enter_deadline(Duration::from_secs(60));
{
let _inner = enter_deadline(Duration::from_millis(1));
std::thread::sleep(Duration::from_millis(20));
}
for _ in 0..(DEADLINE_CHECK_INTERVAL * 4) {
check_deadline().expect("the outer deadline is still live and must not fire");
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn verbatim_prefix_is_stripped_only_for_a_drive_path() {
use std::path::{Path, PathBuf};
let strip = |s: &str| super::strip_verbatim_prefix(Path::new(s));
assert_eq!(
strip(r"\\?\C:\Users\x\f.txt"),
PathBuf::from(r"C:\Users\x\f.txt")
);
assert_eq!(strip(r"\\?\d:\x"), PathBuf::from(r"d:\x"));
assert_eq!(strip(r"C:\Users\x"), PathBuf::from(r"C:\Users\x"));
assert_eq!(strip(r"relative\f"), PathBuf::from(r"relative\f"));
}
#[test]
fn existing_paths_never_probes_a_remote_share() {
let args: Vec<String> = [
"//p/text()",
"\\\\ae-no-such-host.invalid\\share",
"/\\ae-no-such-host.invalid/share",
"\\\\?\\UNC\\ae-no-such-host.invalid\\share",
]
.iter()
.map(|s| s.to_string())
.collect();
for a in &args {
assert_eq!(names_a_remote_share(a), cfg!(windows), "{a}");
}
let started = std::time::Instant::now();
let found = existing_paths(&args);
if cfg!(windows) {
assert!(found.is_empty(), "{found:?}");
assert!(
started.elapsed().as_secs() < 5,
"probed: {:?}",
started.elapsed()
);
}
let started = std::time::Instant::now();
assert!(egress_permitted(&args[1..2], "example.com").is_err());
if cfg!(windows) {
assert!(
started.elapsed().as_secs() < 5,
"probed: {:?}",
started.elapsed()
);
}
let here = std::env::current_dir().unwrap().display().to_string();
assert_eq!(existing_paths(&[here.clone()]), [here]);
assert!(!names_a_remote_share("/tmp"));
assert!(!names_a_remote_share("C:\\x"));
}
use super::*;
use super::ENV_LOCK;
fn clear_env() {
for k in [
"IRONSHELL_MODE",
"IRONSHELL_AGENT",
"IRONSHELL_POLICY",
"IRONSHELL_APPROVE",
"IRONSHELL_APPROVE_ALL",
"IRONSHELL_WORKSPACE",
"IRONSHELL_AUDIT_LOG",
"IRONSHELL_AUDIT_REQUIRED",
"IRONSHELL_MAX_OPS",
"IRONSHELL_MAX_FILES",
"IRONSHELL_MAX_PROCS",
"IRONSHELL_MAX_NET",
"IRONSHELL_TIMEOUT_MS",
] {
std::env::remove_var(k);
}
if let Ok(mut g) = GRANTED.lock() {
g.clear();
}
set_principal(None);
clear_rbac_manager();
governor_reset();
}
fn isolate_audit(tag: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("ae_audit_{}_{}.log", tag, std::process::id()));
let _ = std::fs::remove_file(&p);
std::env::set_var("IRONSHELL_AUDIT_LOG", p.to_string_lossy().to_string());
p
}
#[test]
fn agent_mode_refuses_a_remote_bind_unless_allowed() {
use std::net::{IpAddr, Ipv4Addr};
let _l = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_env();
std::env::remove_var("IRONSHELL_ALLOW_REMOTE_BIND");
let remote = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1));
let loopback = IpAddr::V4(Ipv4Addr::LOCALHOST);
assert!(refuse_remote_bind("nc_listen", remote).is_ok());
std::env::set_var("IRONSHELL_MODE", "agent");
assert!(refuse_remote_bind("nc_listen", loopback).is_ok());
let err = refuse_remote_bind("nc_listen", remote).expect_err("agent mode refuses");
assert!(format!("{err}").contains("in agent mode"), "{err}");
std::env::set_var("IRONSHELL_ALLOW_REMOTE_BIND", "true");
assert!(refuse_remote_bind("nc_listen", remote).is_ok());
clear_env();
std::env::remove_var("IRONSHELL_ALLOW_REMOTE_BIND");
}
#[test]
fn human_mode_allows_everything() {
let _l = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_env();
assert_eq!(decide(Effect::Destructive, Mode::Human), Decision::Allow);
assert_eq!(decide(Effect::Exec, Mode::Human), Decision::Allow);
assert_eq!(decide(Effect::Privileged, Mode::Human), Decision::Allow);
}
#[test]
fn fips_mode_rejects_non_approved_hashes() {
assert!(is_weak_hash("md5") && is_weak_hash("MD5") && is_weak_hash("sha-1"));
assert!(!is_weak_hash("sha256") && !is_weak_hash("sha512"));
let _l = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_env();
assert!(require_fips_hash("md5").is_ok());
std::env::set_var("IRONSHELL_FIPS", "1");
assert!(require_fips_hash("md5").is_err());
assert!(require_fips_hash("sha1").is_err());
assert!(require_fips_hash("sha256").is_ok());
std::env::remove_var("IRONSHELL_FIPS");
}
#[test]
fn destination_hosts_are_read_from_urls_scp_and_bare_names() {
assert_eq!(
destination_host("https://api.example.com/v1/x?q=1").as_deref(),
Some("api.example.com")
);
assert_eq!(
destination_host("http://user:pw@Host.COM:8080/p").as_deref(),
Some("host.com")
);
assert_eq!(destination_host("http://[::1]:80/").as_deref(), Some("::1"));
assert_eq!(
destination_host("git@github.com:org/repo.git").as_deref(),
Some("github.com")
);
assert_eq!(
destination_host("example.org:22").as_deref(),
Some("example.org")
);
assert_eq!(destination_host("localhost").as_deref(), Some("localhost"));
assert_eq!(destination_host("not a url"), None);
assert_eq!(destination_host("--flag"), None);
assert_eq!(destination_host("./dir/file"), None);
}
#[test]
fn egress_allowlist_policy() {
let t = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert!(egress_permitted(&t(&["https://example.com/x"]), "example.com").is_ok());
assert!(egress_permitted(&t(&["https://api.example.com/x"]), "example.com").is_ok());
assert!(
egress_permitted(&t(&["https://a.b.example.com/x"]), "foo.org; example.com").is_ok()
);
assert!(
egress_permitted(&t(&["https://a.b.example.com/x"]), "foo.org,example.com").is_ok()
);
assert!(egress_permitted(&t(&["https://anything.test/x"]), "*").is_ok());
assert!(egress_permitted(&t(&["https://evil.com/x"]), "example.com").is_err());
assert!(egress_permitted(&t(&["https://notexample.com/x"]), "example.com").is_err());
assert!(egress_permitted(&t(&["garbage words"]), "example.com").is_err());
assert!(
egress_permitted(&t(&["https://example.com/f", "out.json"]), "example.com").is_ok()
);
}
#[test]
fn agent_mode_gates_dangerous() {
let _l = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_env();
assert_eq!(decide(Effect::ReadLocal, Mode::Agent), Decision::Allow);
assert_eq!(decide(Effect::Destructive, Mode::Agent), Decision::Approve);
assert_eq!(decide(Effect::Exec, Mode::Agent), Decision::Approve);
assert_eq!(decide(Effect::Privileged, Mode::Agent), Decision::Deny);
}
#[test]
fn network_is_allow_in_the_table_and_approve_for_guard() {
let _l = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_env();
assert_eq!(decide(Effect::Network, Mode::Agent), Decision::Allow);
assert_eq!(
effective_decision(Effect::Network, Mode::Agent),
Decision::Approve
);
assert_eq!(
effective_decision(Effect::Network, Mode::Human),
Decision::Allow
);
std::env::set_var("IRONSHELL_NET_ALLOW", "example.com");
assert_eq!(
effective_decision(Effect::Network, Mode::Agent),
Decision::Allow
);
clear_env();
std::env::set_var("IRONSHELL_POLICY", "permissive");
assert_eq!(
effective_decision(Effect::Network, Mode::Agent),
Decision::Allow
);
clear_env();
}
#[test]
fn permissive_policy_allows_all_in_agent() {
let _l = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_env();
std::env::set_var("IRONSHELL_POLICY", "permissive");
assert_eq!(decide(Effect::Destructive, Mode::Agent), Decision::Allow);
assert_eq!(decide(Effect::Privileged, Mode::Agent), Decision::Allow);
clear_env();
}
#[test]
fn approval_token_is_bound_to_action() {
let _l = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_env();
let a = ApprovalDescriptor::new(
"delete",
"rm",
vec!["/x/a".into()],
json!({"files":1}),
false,
)
.unwrap();
let b = ApprovalDescriptor::new(
"delete",
"rm",
vec!["/x/b".into()],
json!({"files":1}),
false,
)
.unwrap();
assert_ne!(
a.token, b.token,
"different targets must yield different tokens"
);
assert!(a.token.starts_with("apv_"));
}
#[test]
fn chain_tag_is_hmac_sha256_over_the_canonical_core() {
let key = b"audit-test-key-0123456789";
let core = r#"{"builtin":"rm","decision":"allow","mac":"hmac-sha256","seq":7,"ts":"2026-10-29T00:00:00Z"}"#;
assert_eq!(
chain_tag(Some(key), core).unwrap(),
"2863021df08b3835ed91e3978034891196c7467116155ce7eb55d032443480fc"
);
assert_eq!(
chain_tag(None, core).unwrap(),
"43af3b9ad29795952251109b222ce66c9222f573a776b09c0a9e2724f799fe73"
);
assert_ne!(
chain_tag(Some(key), &format!("{core}\n")).unwrap(),
"2863021df08b3835ed91e3978034891196c7467116155ce7eb55d032443480fc"
);
}
#[test]
fn audit_key_id_is_the_first_16_hex_of_domain_separated_sha256() {
let key = b"audit-test-key-0123456789";
assert_eq!(audit_key_id(key).unwrap(), "e8fca1b737b081c3");
}
#[test]
fn ic_fill_random_fills_and_is_not_constant() {
let mut a = [0u8; 64];
let mut b = [0u8; 64];
ic_fill_random("test", &mut a).unwrap();
ic_fill_random("test", &mut b).unwrap();
assert!(a.iter().any(|&x| x != 0), "64 zero bytes from the DRBG");
assert_ne!(a, b, "two draws must differ");
}
#[test]
fn guard_blocks_then_allows_with_token() {
let _l = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_env();
std::env::set_var("IRONSHELL_MODE", "agent");
let log = isolate_audit("approve");
let tmp = std::env::temp_dir();
std::env::set_var("IRONSHELL_WORKSPACE", &tmp);
let target = tmp
.join("ae_safety_test_file")
.to_string_lossy()
.to_string();
let mk = || GuardCtx {
builtin: "rm",
effect: Effect::Destructive,
what: "delete",
targets: vec![target.clone()],
blast_radius: json!({"files": 1}),
reversible: false,
fs_paths: true,
};
let err = guard(mk()).unwrap_err();
assert_eq!(err.code, ErrorCode::NeedsApproval);
let token = err.approval.unwrap().token;
std::env::set_var("IRONSHELL_APPROVE", &token);
assert!(
guard(mk()).is_ok(),
"matching token should permit the action"
);
let _ = std::fs::remove_file(&log);
clear_env();
}
#[test]
fn jail_blocks_paths_outside_workspace() {
let _l = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_env();
std::env::set_var("IRONSHELL_MODE", "agent");
let log = isolate_audit("jail");
let tmp = std::env::temp_dir();
std::env::set_var("IRONSHELL_WORKSPACE", &tmp);
let outside = if cfg!(windows) {
"C:/Windows/System32/x"
} else {
"/etc/x"
};
let ctx = GuardCtx::new("rm", Effect::Destructive, "delete", outside);
let err = guard(ctx).unwrap_err();
assert_eq!(err.code, ErrorCode::OutsideWorkspace);
let _ = std::fs::remove_file(&log);
clear_env();
}
#[test]
fn rbac_authorized_principal_bypasses_approval() {
let _l = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_env();
std::env::set_var("IRONSHELL_MODE", "agent");
let tmp = std::env::temp_dir();
std::env::set_var("IRONSHELL_WORKSPACE", &tmp);
let _log = isolate_audit("rbac");
let mgr = std::sync::Arc::new(crate::auth::RbacManager::new());
mgr.add_role(crate::auth::Role::new("destroyer").with_permission("effect:destructive"));
let user = crate::auth::User::new("alice");
let uid = user.id.clone();
mgr.add_user(user).unwrap();
mgr.assign_role(&uid, "destroyer").unwrap();
set_rbac_manager(mgr);
let target = tmp.join("rbac_target").to_string_lossy().to_string();
let mk = || GuardCtx {
builtin: "rm",
effect: Effect::Destructive,
what: "delete",
targets: vec![target.clone()],
blast_radius: json!({}),
reversible: false,
fs_paths: true,
};
set_principal(None);
assert_eq!(guard(mk()).unwrap_err().code, ErrorCode::NeedsApproval);
set_principal(Some(uid.clone()));
assert!(
guard(mk()).is_ok(),
"principal with effect:destructive should bypass approval"
);
let other = crate::auth::User::new("bob");
let other_id = other.id.clone();
set_principal(Some(other_id));
assert_eq!(guard(mk()).unwrap_err().code, ErrorCode::NeedsApproval);
clear_env();
}
#[test]
fn audit_chain_verifies_and_detects_tampering() {
let _l = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_env();
let mut log = std::env::temp_dir();
log.push(format!("ae_audit_test_{}.log", std::process::id()));
let _ = std::fs::remove_file(&log);
std::env::set_var("IRONSHELL_AUDIT_LOG", log.to_string_lossy().to_string());
audit("rm", Effect::Destructive, "allow", "/x/a", json!({})).unwrap();
audit("sh", Effect::Exec, "approved", "echo hi", json!({})).unwrap();
audit("kill", Effect::Process, "allow", "1234", json!({})).unwrap();
let n = verify_audit(&log).expect("clean chain verifies");
assert_eq!(n, 3);
let content = std::fs::read_to_string(&log).unwrap();
let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
lines[1] = lines[1].replace("echo hi", "rm -rf /");
std::fs::write(&log, lines.join("\n")).unwrap();
assert!(verify_audit(&log).is_err(), "tampering must be detected");
let _ = std::fs::remove_file(&log);
clear_env();
}
#[test]
fn redact_str_scrubs_known_secret_shapes() {
let (r, c) = redact_str("authorization: Bearer sk-abcdefghijklmnopqrstuvwxyz12");
assert!(c && r.contains("[REDACTED]") && !r.contains("abcdefghij"));
let (r, _) = redact_str("token ghp_0123456789abcdefABCDEFghijklmnop12");
assert!(r.contains("[REDACTED]") && !r.contains("ghp_0123"));
let (r, _) = redact_str("AKIAIOSFODNN7EXAMPLE in the config");
assert!(r.contains("[REDACTED]") && !r.contains("AKIAIOSF"));
let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N";
let (r, _) = redact_str(jwt);
assert!(r.contains("[REDACTED]") && !r.contains("eyJzdWIi"));
let pem =
"-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA\n-----END RSA PRIVATE KEY-----";
let (r, _) = redact_str(pem);
assert!(r.contains("[REDACTED]") && !r.contains("MIIEpAIB"));
}
#[test]
fn redact_str_scrubs_url_credentials_and_assignments() {
let (r, _) = redact_str("postgres://admin:hunter2pass@db.internal:5432/app");
assert!(
r.contains("admin:[REDACTED]@") && !r.contains("hunter2pass"),
"password redacted, scheme/user/host kept: {r}"
);
let (r, _) = redact_str("password = swordfish123");
assert!(r.contains("[REDACTED]") && !r.contains("swordfish123"));
let (r, _) = redact_str("api_key: \"abcdef123456\"");
assert!(r.contains("[REDACTED]") && !r.contains("abcdef123456"));
}
#[test]
fn redact_str_keeps_json_quotes_handles_and_variable_names() {
let (r, c) = redact_str(r#"{"password":"swordfish123","n":1}"#);
assert!(c);
assert_eq!(r, r#"{"password":"[REDACTED]","n":1}"#);
serde_json::from_str::<Json>(&r).expect("still JSON");
let line = r#"{"token":"apv_0123456789abcdef","what":"exec"}"#;
assert_eq!(redact_str(line), (line.to_string(), false));
let msg = "deploy.sh: line 1: DEPLOY_TOKEN: DEPLOY_TOKEN must be set";
assert_eq!(redact_str(msg), (msg.to_string(), false));
let (r, _) = redact_str("token: $GITHUB_TOKEN");
assert_eq!(r, "token: $GITHUB_TOKEN");
let (r, _) = redact_str("token: Ab_Cd_EfGh123");
assert_eq!(r, "token: [REDACTED]");
let env = r#"{"x":"API_KEY=sk-test-123456789\nDB_PASSWORD=hunter2hunter2"}"#;
let (r, c) = redact_str(env);
assert!(c);
assert!(r.contains(r"\nDB_PASSWORD=[REDACTED]"), "{r}");
assert!(
!r.contains("sk-test-123456789") && !r.contains("hunter2hunter2"),
"{r}"
);
let (r, _) = redact_str(r"password=ab\cdefgh");
assert_eq!(r, "password=[REDACTED]");
}
#[test]
fn redact_str_leaves_ordinary_text_untouched() {
let plain = "the quick brown fox reads file.txt at 12:00 and exits 0";
let (r, c) = redact_str(plain);
assert!(!c, "no secret shape — must report unchanged");
assert_eq!(r, plain, "ordinary prose must pass through byte-for-byte");
}
#[test]
fn is_secret_name_matches_indicators_not_false_positives() {
for yes in [
"OPENAI_API_KEY",
"AWS_SECRET_ACCESS_KEY",
"GITHUB_TOKEN",
"DB_PASSWORD",
"ANTHROPIC_KEY",
"client_secret",
] {
assert!(is_secret_name(yes), "{yes} should be a secret name");
}
for no in ["PATH", "KEYBOARD", "MONKEY", "HOME", "USER", "LANG"] {
assert!(!is_secret_name(no), "{no} must not be flagged");
}
}
#[test]
fn redact_json_scrubs_values_and_secret_named_keys() {
let mut v = json!({
"note": "use sk-abcdefghijklmnopqrstuvwx99 to auth",
"API_KEY": "literally-anything",
"nested": { "PASSWORD": "p", "ok": "plain text" },
"list": ["ghp_0123456789abcdefABCDEFghijklmnop12", "fine"],
});
redact_json(&mut v);
assert_eq!(v["API_KEY"], json!("[REDACTED]"));
assert_eq!(v["nested"]["PASSWORD"], json!("[REDACTED]"));
assert_eq!(v["nested"]["ok"], json!("plain text"));
assert!(v["note"].as_str().unwrap().contains("[REDACTED]"));
assert!(!v["note"].as_str().unwrap().contains("sk-abcdef"));
assert_eq!(v["list"][1], json!("fine"));
assert!(v["list"][0].as_str().unwrap().contains("[REDACTED]"));
}
#[test]
fn env_secret_gated_is_agent_only_and_policy_aware() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_env();
std::env::remove_var("IRONSHELL_REDACT");
std::env::remove_var("IRONSHELL_SECRETS");
assert!(!env_secret_gated("OPENAI_API_KEY"));
std::env::set_var("IRONSHELL_MODE", "agent");
assert!(env_secret_gated("OPENAI_API_KEY"));
assert!(!env_secret_gated("HOME"));
std::env::set_var("IRONSHELL_SECRETS", "allow");
assert!(!env_secret_gated("OPENAI_API_KEY"));
std::env::remove_var("IRONSHELL_SECRETS");
std::env::set_var("IRONSHELL_REDACT", "off");
assert!(!env_secret_gated("OPENAI_API_KEY"));
clear_env();
std::env::remove_var("IRONSHELL_REDACT");
}
#[test]
fn governor_is_inert_in_human_mode_and_when_unset() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_env();
std::env::set_var("IRONSHELL_MAX_FILES", "1");
for _ in 0..5 {
assert!(governor_admit(Effect::Destructive, "rm").is_ok());
}
clear_env();
std::env::set_var("IRONSHELL_MODE", "agent");
for _ in 0..50 {
assert!(governor_admit(Effect::Destructive, "rm").is_ok());
}
clear_env();
}
#[test]
fn governor_breaches_file_and_op_budgets_independently() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_env();
std::env::set_var("IRONSHELL_MODE", "agent");
std::env::set_var("IRONSHELL_MAX_FILES", "2");
assert!(governor_admit(Effect::WriteLocal, "file_write").is_ok());
assert!(governor_admit(Effect::Destructive, "rm").is_ok());
let err = governor_admit(Effect::WriteLocal, "file_write").unwrap_err();
assert_eq!(err.code, ErrorCode::BudgetExceeded);
assert!(err.to_json()["error"]["retryable"] == json!(false));
assert!(governor_admit(Effect::Process, "proc_kill").is_ok());
let snap = governor_snapshot();
assert_eq!(snap["used"]["files"], json!(3)); assert_eq!(snap["limits"]["max_files"], json!(2));
clear_env();
}
#[test]
fn governor_enforces_network_egress_budget() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_env();
std::env::set_var("IRONSHELL_MODE", "agent");
std::env::set_var("IRONSHELL_MAX_NET", "2");
assert!(governor_admit(Effect::Network, "http_get").is_ok());
assert!(governor_admit(Effect::Network, "web_fetch").is_ok());
let err = governor_admit(Effect::Network, "http_get").unwrap_err();
assert_eq!(err.code, ErrorCode::BudgetExceeded);
assert!(err.message.contains("network-egress"));
assert!(governor_admit(Effect::WriteLocal, "file_write").is_ok());
let snap = governor_snapshot();
assert_eq!(snap["used"]["net"], json!(3));
assert_eq!(snap["limits"]["max_net"], json!(2));
clear_env();
}
#[test]
fn governor_enforces_total_op_budget_across_effects() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_env();
std::env::set_var("IRONSHELL_MODE", "agent");
std::env::set_var("IRONSHELL_MAX_OPS", "3");
assert!(governor_admit(Effect::WriteLocal, "file_write").is_ok());
assert!(governor_admit(Effect::Process, "proc_kill").is_ok());
assert!(governor_admit(Effect::Exec, "sh").is_ok());
let err = governor_admit(Effect::ReadLocal, "cat").unwrap_err();
assert_eq!(err.code, ErrorCode::BudgetExceeded);
clear_env();
}
#[test]
fn governor_enforces_wall_clock_timeout() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_env();
std::env::set_var("IRONSHELL_MODE", "agent");
std::env::set_var("IRONSHELL_TIMEOUT_MS", "1");
assert!(governor_admit(Effect::WriteLocal, "file_write").is_ok());
std::thread::sleep(std::time::Duration::from_millis(15));
let err = governor_admit(Effect::WriteLocal, "file_write").unwrap_err();
assert_eq!(err.code, ErrorCode::BudgetExceeded);
assert!(err.message.contains("wall-clock"));
clear_env();
}
#[test]
fn governor_reset_starts_a_fresh_envelope() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_env();
std::env::set_var("IRONSHELL_MODE", "agent");
std::env::set_var("IRONSHELL_MAX_OPS", "1");
assert!(governor_admit(Effect::WriteLocal, "file_write").is_ok());
assert!(governor_admit(Effect::WriteLocal, "file_write").is_err());
governor_reset();
assert!(governor_admit(Effect::WriteLocal, "file_write").is_ok());
clear_env();
}
#[cfg(windows)]
#[test]
fn same_existing_file_matches_hard_links_only() {
let dir = std::env::temp_dir().join(format!("ae-fileid-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let a = dir.join("a.txt");
let link = dir.join("link.txt");
let other = dir.join("other.txt");
let missing = dir.join("missing.txt");
std::fs::write(&a, "a").unwrap();
std::fs::hard_link(&a, &link).unwrap();
std::fs::write(&other, "b").unwrap();
assert!(same_existing_file(&a, &link));
assert!(same_existing_file(&link, &a));
assert!(!same_existing_file(&a, &other));
assert!(!same_existing_file(&a, &missing));
assert!(!same_existing_file(&missing, &missing));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn lock_poisoned_is_bad_state_and_names_the_builtin() {
let err = lock_poisoned("feature_set", "the feature table");
let se = err.downcast_ref::<SafetyError>().expect("a SafetyError");
assert_eq!(se.code, ErrorCode::BadState);
assert!(se.message.contains("feature_set"), "{}", se.message);
assert_eq!(se.builtin, "feature_set");
}
fn lock_feature_table(m: &std::sync::Mutex<u32>) -> anyhow::Result<u32> {
let g = m
.lock()
.map_err(|_| lock_poisoned("feature_set", "the feature table"))?;
Ok(*g)
}
#[test]
fn a_poisoned_mutex_maps_to_bad_state() {
let m = std::sync::Arc::new(std::sync::Mutex::new(7u32));
let held = std::sync::Arc::clone(&m);
let joined = std::thread::spawn(move || {
let _guard = held.lock().unwrap();
panic!("poison the lock for the test");
})
.join();
assert!(joined.is_err(), "the thread must have panicked");
let err = lock_feature_table(&m).expect_err("a poisoned mutex must not lock");
let se = err.downcast_ref::<SafetyError>().expect("a SafetyError");
assert_eq!(se.code, ErrorCode::BadState);
assert!(se.message.contains("feature_set"), "{}", se.message);
}
}
#[cfg(test)]
mod mirror_and_git_install_tests {
use super::*;
use std::ffi::OsString;
use std::path::PathBuf;
fn env_of(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<OsString> {
move |name: &str| {
pairs
.iter()
.find(|(k, _)| *k == name)
.map(|(_, v)| OsString::from(v))
}
}
#[test]
fn daemon_json_names_a_mirror_only_when_the_list_is_non_empty() {
assert_eq!(daemon_json_mirrors("{}"), Ok(false));
assert_eq!(
daemon_json_mirrors(r#"{"registry-mirrors": []}"#),
Ok(false)
);
assert_eq!(
daemon_json_mirrors(r#"{"registry-mirrors": null}"#),
Ok(false)
);
assert_eq!(
daemon_json_mirrors(r#"{"registry-mirrors": ["https://mirror.example.invalid"]}"#),
Ok(true)
);
assert_eq!(
daemon_json_mirrors(r#"{"log-level": "info", "registry-mirrors": ["https://m"]}"#),
Ok(true)
);
}
#[test]
fn daemon_json_that_does_not_parse_fails_closed() {
assert!(daemon_json_mirrors("").is_err());
assert!(daemon_json_mirrors("{").is_err());
assert!(daemon_json_mirrors("[]").is_err());
assert!(daemon_json_mirrors(r#"{"registry-mirrors": "https://m"}"#).is_err());
assert!(daemon_json_mirrors(r#"{"registry-mirrors": {"a": 1}}"#).is_err());
}
#[test]
fn registries_conf_names_a_mirror_or_a_search_list_in_use() {
let mirror = "[[registry]]\nlocation = \"docker.io\"\n\n[[registry.mirror]]\nlocation = \"mirror.example.invalid\"\n";
assert_eq!(registries_conf_redirects(mirror), Ok(true));
assert_eq!(
registries_conf_redirects("unqualified-search-registries = [\"docker.io\"]\n"),
Ok(true)
);
assert_eq!(
registries_conf_redirects("[registries.search]\nregistries = [\"quay.io\"]\n"),
Ok(true)
);
}
#[test]
fn registries_conf_without_a_mirror_or_search_list_is_no_redirect() {
assert_eq!(registries_conf_redirects(""), Ok(false));
assert_eq!(
registries_conf_redirects("unqualified-search-registries = []\n"),
Ok(false)
);
assert_eq!(
registries_conf_redirects("[[registry]]\nlocation = \"docker.io\"\n"),
Ok(false)
);
assert_eq!(
registries_conf_redirects("[[registry]]\nlocation = \"docker.io\"\nmirror = []\n"),
Ok(false)
);
}
#[test]
fn registries_conf_that_does_not_parse_fails_closed() {
assert!(registries_conf_redirects("not = [toml").is_err());
assert!(
registries_conf_redirects("unqualified-search-registries = \"docker.io\"\n").is_err()
);
assert!(registries_conf_redirects("registry = \"docker.io\"\n").is_err());
}
#[test]
fn mirror_paths_follow_docker_and_containers_locations() {
let home = PathBuf::from("/fake/home");
let paths = container_mirror_paths(env_of(&[
("HOME", "/fake/home"),
("ProgramData", "/fake/pd"),
]));
assert_eq!(
paths,
vec![
(
MirrorConfig::DaemonJson,
PathBuf::from("/etc/docker/daemon.json")
),
(
MirrorConfig::DaemonJson,
PathBuf::from("/fake/pd")
.join("docker")
.join("config")
.join("daemon.json"),
),
(
MirrorConfig::RegistriesConf,
PathBuf::from("/etc/containers/registries.conf"),
),
(
MirrorConfig::DaemonJson,
home.join(".docker").join("daemon.json")
),
(
MirrorConfig::RegistriesConf,
home.join(".config")
.join("containers")
.join("registries.conf"),
),
(
MirrorConfig::DaemonJson,
home.join(".config").join("docker").join("daemon.json"),
),
]
);
}
#[test]
fn an_unset_or_empty_variable_names_no_mirror_location() {
let paths = container_mirror_paths(env_of(&[("HOME", ""), ("ProgramData", "")]));
assert_eq!(
paths,
vec![
(
MirrorConfig::DaemonJson,
PathBuf::from("/etc/docker/daemon.json")
),
(
MirrorConfig::RegistriesConf,
PathBuf::from("/etc/containers/registries.conf"),
),
]
);
let paths = container_mirror_paths(env_of(&[("USERPROFILE", "/fake/up")]));
assert!(paths.contains(&(
MirrorConfig::DaemonJson,
PathBuf::from("/fake/up")
.join(".docker")
.join("daemon.json"),
)));
}
#[test]
fn a_set_xdg_config_home_is_read_together_with_home_config() {
let root = Path::new("/fake/root");
let xdg = PathBuf::from("/fake/xdg");
let home_config = PathBuf::from("/fake/home").join(".config");
let paths = container_mirror_paths_at(
&env_of(&[("HOME", "/fake/home"), ("XDG_CONFIG_HOME", "/fake/xdg")]),
root,
);
for (format, path) in [
(
MirrorConfig::RegistriesConf,
xdg.join("containers").join("registries.conf"),
),
(
MirrorConfig::DaemonJson,
xdg.join("docker").join("daemon.json"),
),
(
MirrorConfig::RegistriesConf,
home_config.join("containers").join("registries.conf"),
),
(
MirrorConfig::DaemonJson,
home_config.join("docker").join("daemon.json"),
),
] {
assert!(
paths.contains(&(format, path.clone())),
"{path:?} in {paths:?}"
);
}
let paths = container_mirror_paths_at(
&env_of(&[
("HOME", "/fake/home"),
("XDG_CONFIG_HOME", "/fake/home/.config"),
]),
root,
);
let registries = home_config.join("containers").join("registries.conf");
let listed = paths
.iter()
.filter(|(f, p)| *f == MirrorConfig::RegistriesConf && *p == registries)
.count();
assert_eq!(listed, 1, "{paths:?}");
let paths = container_mirror_paths_at(
&env_of(&[("HOME", "/fake/home"), ("XDG_CONFIG_HOME", "")]),
root,
);
assert!(paths.contains(&(MirrorConfig::RegistriesConf, registries)));
assert!(!paths.iter().any(|(_, p)| p.starts_with(&xdg)));
}
#[test]
fn a_mirror_under_home_config_is_refused_while_xdg_points_elsewhere() {
let f = MirrorFixture::new("home_config_under_xdg");
let xdg = [("XDG_CONFIG_HOME", f.xdg().to_string_lossy().into_owned())];
f.put(f.xdg().join("containers/registries.conf"), "");
f.put(
f.home().join(".config/containers/registries.conf"),
MIRROR_CONF,
);
assert_eq!(
runtime_mirror_refusal_at("podman_run", f.env(&xdg), &f.system()),
Err(MIRROR_NAMED)
);
let g = MirrorFixture::new("home_config_dropin_under_xdg");
let xdg = [("XDG_CONFIG_HOME", g.xdg().to_string_lossy().into_owned())];
g.put(
g.home().join(".config/containers/registries.conf.d/m.conf"),
MIRROR_CONF,
);
assert_eq!(
runtime_mirror_refusal_at("podman_run", g.env(&xdg), &g.system()),
Err(MIRROR_NAMED)
);
let h = MirrorFixture::new("home_config_docker_under_xdg");
let xdg = [("XDG_CONFIG_HOME", h.xdg().to_string_lossy().into_owned())];
h.put(
h.home().join(".config/docker/daemon.json"),
r#"{"registry-mirrors": ["https://m.example.invalid"]}"#,
);
assert_eq!(
runtime_mirror_refusal_at("docker_run", h.env(&xdg), &h.system()),
Err(MIRROR_NAMED)
);
}
struct MirrorFixture {
root: PathBuf,
}
impl MirrorFixture {
fn new(tag: &str) -> Self {
Self {
root: temp_dir(tag),
}
}
fn system(&self) -> PathBuf {
self.root.join("sys")
}
fn home(&self) -> PathBuf {
self.root.join("home")
}
fn xdg(&self) -> PathBuf {
self.root.join("xdg")
}
fn put(&self, path: PathBuf, text: &str) {
std::fs::create_dir_all(path.parent().expect("parent")).expect("fixture dir");
std::fs::write(&path, text).expect("fixture file");
}
fn env(&self, extra: &[(&str, String)]) -> impl Fn(&str) -> Option<OsString> {
let mut pairs: Vec<(String, String)> = vec![(
"HOME".to_string(),
self.home().to_string_lossy().into_owned(),
)];
pairs.extend(extra.iter().map(|(k, v)| (k.to_string(), v.clone())));
move |name: &str| {
pairs
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| OsString::from(v))
}
}
}
impl Drop for MirrorFixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
const MIRROR_CONF: &str =
"[[registry]]\nlocation = \"docker.io\"\n\n[[registry.mirror]]\nlocation = \"m.example.invalid\"\n";
#[test]
fn dropins_are_listed_in_name_order_and_only_conf_files_count() {
let f = MirrorFixture::new("dropin_order");
let dir = f.system().join("etc/containers/registries.conf.d");
f.put(dir.join("b.conf"), "");
f.put(dir.join("a.conf"), "");
f.put(dir.join("notes.txt"), MIRROR_CONF);
assert_eq!(
dropin_files(&[dir.clone()]),
Ok(vec![
(MirrorConfig::RegistriesConf, dir.join("a.conf")),
(MirrorConfig::RegistriesConf, dir.join("b.conf")),
])
);
}
#[test]
fn a_system_dropin_that_names_a_mirror_refuses_podman_and_docker_ignores_it() {
let f = MirrorFixture::new("dropin_system");
f.put(
f.system()
.join("etc/containers/registries.conf.d/mirror.conf"),
MIRROR_CONF,
);
assert_eq!(
runtime_mirror_refusal_at("podman_run", f.env(&[]), &f.system()),
Err(MIRROR_NAMED)
);
assert_eq!(
runtime_mirror_refusal_at("docker_run", f.env(&[]), &f.system()),
Ok(())
);
}
#[test]
fn a_user_dropin_is_read_under_xdg_config_home_or_home_config() {
let f = MirrorFixture::new("dropin_user");
let xdg = [("XDG_CONFIG_HOME", f.xdg().to_string_lossy().into_owned())];
f.put(
f.xdg().join("containers/registries.conf.d/m.conf"),
MIRROR_CONF,
);
assert_eq!(
runtime_mirror_refusal_at("podman_run", f.env(&xdg), &f.system()),
Err(MIRROR_NAMED)
);
assert_eq!(
runtime_mirror_refusal_at("podman_run", f.env(&[]), &f.system()),
Ok(())
);
f.put(
f.home().join(".config/containers/registries.conf.d/m.conf"),
MIRROR_CONF,
);
assert_eq!(
runtime_mirror_refusal_at("podman_run", f.env(&[]), &f.system()),
Err(MIRROR_NAMED)
);
}
#[test]
fn an_absent_dropin_directory_or_a_non_conf_file_is_no_mirror() {
let f = MirrorFixture::new("dropin_absent");
assert_eq!(
runtime_mirror_refusal_at("podman_run", f.env(&[]), &f.system()),
Ok(())
);
f.put(
f.system()
.join("etc/containers/registries.conf.d/notes.txt"),
MIRROR_CONF,
);
assert_eq!(
runtime_mirror_refusal_at("podman_run", f.env(&[]), &f.system()),
Ok(())
);
}
#[test]
fn a_dropin_path_that_exists_but_cannot_be_listed_refuses() {
let f = MirrorFixture::new("dropin_unlistable");
f.put(f.system().join("etc/containers/registries.conf.d"), "");
assert_eq!(
runtime_mirror_refusal_at("podman_run", f.env(&[]), &f.system()),
Err(MIRROR_UNREADABLE)
);
}
#[test]
fn more_than_64_dropin_files_refuse_and_64_do_not() {
let f = MirrorFixture::new("dropin_cap");
let dir = f.system().join("etc/containers/registries.conf.d");
for i in 0..64 {
f.put(dir.join(format!("{i:02}.conf")), "");
}
assert_eq!(
runtime_mirror_refusal_at("podman_run", f.env(&[]), &f.system()),
Ok(())
);
f.put(dir.join("64.conf"), "");
assert_eq!(
runtime_mirror_refusal_at("podman_run", f.env(&[]), &f.system()),
Err(MIRROR_TOO_MANY)
);
}
#[test]
fn rootless_docker_daemon_json_is_read_under_xdg_or_home_config() {
let f = MirrorFixture::new("rootless_docker");
let mirror_json = r#"{"registry-mirrors": ["https://m.example.invalid"]}"#;
let xdg = [("XDG_CONFIG_HOME", f.xdg().to_string_lossy().into_owned())];
f.put(f.xdg().join("docker/daemon.json"), mirror_json);
assert_eq!(
runtime_mirror_refusal_at("docker_run", f.env(&xdg), &f.system()),
Err(MIRROR_NAMED)
);
assert_eq!(
runtime_mirror_refusal_at("docker_run", f.env(&[]), &f.system()),
Ok(())
);
f.put(f.home().join(".config/docker/daemon.json"), mirror_json);
assert_eq!(
runtime_mirror_refusal_at("docker_run", f.env(&[]), &f.system()),
Err(MIRROR_NAMED)
);
assert_eq!(
runtime_mirror_refusal_at("podman_run", f.env(&[]), &f.system()),
Ok(())
);
}
#[test]
fn a_remote_docker_daemon_refuses_for_docker_only() {
let f = MirrorFixture::new("docker_remote");
for name in ["DOCKER_HOST", "DOCKER_CONTEXT"] {
assert_eq!(
runtime_mirror_refusal_at(
"docker_pull",
f.env(&[(name, "x".to_string())]),
&f.system()
),
Err(MIRROR_REMOTE_DOCKER),
"{name}"
);
}
assert_eq!(
runtime_mirror_refusal_at(
"docker_pull",
f.env(&[("DOCKER_HOST", String::new())]),
&f.system()
),
Ok(()),
"an empty DOCKER_HOST is unset"
);
assert_eq!(
runtime_mirror_refusal_at(
"podman_pull",
f.env(&[("DOCKER_HOST", "x".to_string())]),
&f.system()
),
Ok(()),
"podman does not read DOCKER_HOST"
);
}
#[test]
fn a_remote_podman_engine_refuses_for_podman_only() {
let f = MirrorFixture::new("podman_remote");
for name in ["CONTAINER_HOST", "CONTAINER_CONNECTION"] {
assert_eq!(
runtime_mirror_refusal_at(
"podman_pull",
f.env(&[(name, "x".to_string())]),
&f.system()
),
Err(MIRROR_REMOTE_PODMAN),
"{name}"
);
assert_eq!(
runtime_mirror_refusal_at(
"docker_pull",
f.env(&[(name, "x".to_string())]),
&f.system()
),
Ok(()),
"docker does not read {name}"
);
}
assert_eq!(
runtime_mirror_refusal_at(
"podman_pull",
f.env(&[("CONTAINER_HOST", String::new())]),
&f.system()
),
Ok(()),
"an empty CONTAINER_HOST is unset"
);
}
#[test]
fn a_switched_docker_context_in_config_json_refuses_for_docker() {
let f = MirrorFixture::new("docker_context");
let cfg = f.home().join(".docker/config.json");
f.put(cfg.clone(), r#"{"currentContext": "remote"}"#);
assert_eq!(
runtime_mirror_refusal_at("docker_run", f.env(&[]), &f.system()),
Err(MIRROR_CONTEXT_DOCKER)
);
assert_eq!(
runtime_mirror_refusal_at("podman_run", f.env(&[]), &f.system()),
Ok(()),
"podman does not read the docker config.json"
);
for text in [
r#"{"currentContext": "default"}"#,
"{}",
r#"{"currentContext": null}"#,
r#"{"currentContext": ""}"#,
] {
f.put(cfg.clone(), text);
assert_eq!(
runtime_mirror_refusal_at("docker_run", f.env(&[]), &f.system()),
Ok(()),
"{text}"
);
}
f.put(cfg.clone(), "{ not json");
assert_eq!(
runtime_mirror_refusal_at("docker_run", f.env(&[]), &f.system()),
Err(MIRROR_UNPARSED)
);
f.put(cfg.clone(), r#"{"currentContext": 5}"#);
assert_eq!(
runtime_mirror_refusal_at("docker_run", f.env(&[]), &f.system()),
Err(MIRROR_UNPARSED)
);
}
#[test]
fn docker_config_env_names_the_config_json_instead_of_home() {
let f = MirrorFixture::new("docker_config_env");
f.put(
f.home().join(".docker/config.json"),
r#"{"currentContext": "remote"}"#,
);
let dc = [(
"DOCKER_CONFIG",
f.root.join("dc").to_string_lossy().into_owned(),
)];
assert_eq!(
runtime_mirror_refusal_at("docker_run", f.env(&dc), &f.system()),
Ok(()),
"HOME's config.json is not read when DOCKER_CONFIG is set"
);
f.put(
f.root.join("dc/config.json"),
r#"{"currentContext": "remote"}"#,
);
assert_eq!(
runtime_mirror_refusal_at("docker_run", f.env(&dc), &f.system()),
Err(MIRROR_CONTEXT_DOCKER)
);
}
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("ae_mirror_{tag}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir");
dir
}
#[test]
fn a_listed_file_that_names_a_mirror_refuses_and_an_absent_one_does_not() {
let dir = temp_dir("list");
let absent = dir.join("absent.json");
let daemon = dir.join("daemon.json");
let conf = dir.join("registries.conf");
std::fs::write(&daemon, "{}").expect("daemon.json");
std::fs::write(
&conf,
"[[registry]]\nlocation = \"docker.io\"\n\n[[registry.mirror]]\nlocation = \"m\"\n",
)
.expect("registries.conf");
assert_eq!(
mirror_refusal_in(&[(MirrorConfig::DaemonJson, absent.clone())]),
Ok(())
);
assert_eq!(
mirror_refusal_in(&[(MirrorConfig::DaemonJson, daemon.clone())]),
Ok(())
);
assert_eq!(
mirror_refusal_in(&[
(MirrorConfig::DaemonJson, absent.clone()),
(MirrorConfig::RegistriesConf, conf.clone()),
]),
Err(MIRROR_NAMED)
);
assert_eq!(
mirror_refusal_in(&[(MirrorConfig::DaemonJson, conf.clone())]),
Err(MIRROR_UNPARSED)
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_listed_file_that_cannot_be_parsed_refuses() {
let dir = temp_dir("bad");
let bad = dir.join("daemon.json");
std::fs::write(&bad, "{ not json").expect("bad daemon.json");
assert_eq!(
mirror_refusal_in(&[(MirrorConfig::DaemonJson, bad)]),
Err(MIRROR_UNPARSED)
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_remote_share_path_refuses_without_being_probed() {
if !cfg!(windows) {
return;
}
assert_eq!(
mirror_refusal_in(&[(
MirrorConfig::DaemonJson,
PathBuf::from(r"\\ae-no-such-host\share\daemon.json"),
)]),
Err(MIRROR_UNREADABLE)
);
}
#[test]
fn containers_conf_override_refuses_without_reading_anything() {
assert_eq!(
container_mirror_refusal(env_of(&[("CONTAINERS_REGISTRIES_CONF", "/fake/x.conf")])),
Err(MIRROR_ENV)
);
}
#[test]
fn git_install_dir_is_derived_from_the_program_path_alone() {
let root = r"C:\Program Files\Git";
assert_eq!(
git_install_dir_of(r"C:\Program Files\Git\cmd\git.exe").as_deref(),
Some(root)
);
assert_eq!(
git_install_dir_of(r"C:\Program Files\Git\mingw64\bin\git.exe").as_deref(),
Some(root)
);
assert_eq!(
git_install_dir_of(r"C:\Program Files\Git\bin\git.exe").as_deref(),
Some(root)
);
assert_eq!(
git_install_dir_of("D:/Tools/Git/cmd/GIT.EXE").as_deref(),
Some(r"D:\Tools\Git")
);
assert_eq!(
git_install_dir_of(r"C:\Program Files\Git\cmd\git-lfs.exe"),
None
);
assert_eq!(git_install_dir_of(r"C:\Program Files\Git\git.exe"), None);
assert_eq!(git_install_dir_of(r"C:\Tools\Git\usr\bin\rev.exe"), None);
assert_eq!(git_install_dir_of("git.exe"), None);
}
#[test]
fn git_config_paths_add_the_install_files_only_when_git_is_on_path() {
let plain = git_config_paths(env_of(&[("HOME", "/fake/home")]));
assert_eq!(plain[0], PathBuf::from("/etc/gitconfig"));
assert!(!plain
.iter()
.any(|p| p.ends_with("gitconfig") && p.to_string_lossy().contains("mingw64")));
let none = git_config_paths(env_of(&[
("HOME", "/fake/home"),
("PATH", r"\\ae-no-such-host\share"),
]));
assert_eq!(none, plain);
}
}