mod blast_paths;
mod blast_radius;
mod entry_point_check;
mod self_mod;
mod token_budget;
use clap::Subcommand;
use serde::Deserialize;
use std::io::Read;
use std::sync::LazyLock;
#[derive(Subcommand)]
pub enum GuardAction {
Destructive,
TokenBudget {
#[arg(long)]
tool: Option<String>,
},
BlastRadius,
SelfMod,
EntryPointCheck,
}
pub fn dispatch(action: GuardAction) {
let code = match action {
GuardAction::Destructive => cmd_destructive(),
GuardAction::TokenBudget { tool } => token_budget::cmd_token_budget(tool),
GuardAction::BlastRadius => blast_radius::cmd_blast_radius(),
GuardAction::SelfMod => self_mod::cmd_self_mod(),
GuardAction::EntryPointCheck => entry_point_check::cmd_entry_point_check(),
};
std::process::exit(code);
}
#[derive(Deserialize, Default)]
struct HookEvent {
#[serde(default)]
tool_name: String,
#[serde(default)]
tool_input: serde_json::Value,
}
const COMMAND_LIKE_KEYS: &[&str] = &[
"command", "commands", "cmd", "script", "exec", "execute", "sql", "statement", "shell", "bash", "sh",
];
fn tokenize_key(key: &str) -> Vec<String> {
let chars: Vec<char> = key.chars().collect();
let mut spaced = String::new();
for (i, &ch) in chars.iter().enumerate() {
if ch.is_uppercase() && i > 0 {
let prev = chars[i - 1];
let prev_lower_or_digit = prev.is_lowercase() || prev.is_ascii_digit();
let acronym_to_word_boundary =
prev.is_uppercase() && chars.get(i + 1).is_some_and(|c| c.is_lowercase());
if prev_lower_or_digit || acronym_to_word_boundary {
spaced.push('_');
}
}
spaced.push(ch.to_ascii_lowercase());
}
spaced.split(|c: char| !c.is_alphanumeric()).filter(|s| !s.is_empty()).map(str::to_string).collect()
}
fn is_command_like_key(key: &str) -> bool {
tokenize_key(key).iter().any(|t| COMMAND_LIKE_KEYS.contains(&t.as_str()))
}
const MAX_COLLECT_DEPTH: usize = 32;
fn collect_command_like_strings(v: &serde_json::Value, out: &mut Vec<String>) {
collect_command_like_strings_at(v, out, 0);
}
fn collect_command_like_strings_at(v: &serde_json::Value, out: &mut Vec<String>, depth: usize) {
if depth >= MAX_COLLECT_DEPTH {
return;
}
match v {
serde_json::Value::Object(map) => {
for (k, val) in map {
let key_is_command_like = is_command_like_key(k);
match val {
serde_json::Value::String(s) if key_is_command_like => out.push(s.clone()),
serde_json::Value::Array(arr) if key_is_command_like => {
for item in arr {
if let serde_json::Value::String(s) = item {
out.push(s.clone());
}
}
}
_ => {}
}
collect_command_like_strings_at(val, out, depth + 1);
}
}
serde_json::Value::Array(arr) => {
for val in arr {
collect_command_like_strings_at(val, out, depth + 1);
}
}
_ => {}
}
}
fn destructive_patterns() -> [(&'static str, &'static str); 2] {
[
(
r"(?i)\b(DROP\s+(TABLE|DATABASE|SCHEMA)|TRUNCATE\s+TABLE)\b",
"Blocked: destructive SQL (DROP TABLE / TRUNCATE) detected. Database migrations must be reversible. Use ALTER/soft-delete patterns and ask the human to confirm schema drops.",
),
(
r"npm\s+publish|yarn\s+publish|pnpm\s+publish",
"Blocked: publishing to npm requires explicit human approval. Ask the human to run this command manually.",
),
]
}
static RE_GIT_OR_RM: LazyLock<regex::Regex> = LazyLock::new(|| regex::Regex::new(r"\b(git|rm)\b").unwrap());
static RE_ADJACENT_VAR_SPLICE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"[A-Za-z]\$\{?[A-Za-z_][A-Za-z0-9_]*\}?[A-Za-z]").unwrap());
static RE_BRACE_EXPANSION: LazyLock<regex::Regex> = LazyLock::new(|| regex::Regex::new(r"\{[^{}]*,[^{}]*\}").unwrap());
static RE_PUSH_TO_MAIN: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"\s(origin\s+)?(main|master)\b").unwrap());
static RE_RESET_HARD: LazyLock<regex::Regex> = LazyLock::new(|| regex::Regex::new(r"--hard\b").unwrap());
static DESTRUCTIVE_PATTERNS_COMPILED: LazyLock<Vec<(regex::Regex, &'static str)>> = LazyLock::new(|| {
destructive_patterns()
.into_iter()
.map(|(pattern, reason)| (regex::Regex::new(pattern).expect("destructive_patterns() must contain only valid, fixed regex strings"), reason))
.collect()
});
fn strip_tok(raw: &str) -> String {
let mut t = raw.to_string();
if t.starts_with("$'") && t.ends_with('\'') && t.len() >= 3 {
t = t[2..t.len() - 1].to_string();
}
t = t.replace('\\', ""); if t.len() >= 2 {
if t.starts_with('"') && t.ends_with('"') {
t = t[1..t.len() - 1].to_string();
} else if t.starts_with('\'') && t.ends_with('\'') {
t = t[1..t.len() - 1].to_string();
}
}
t
}
const GIT_GLOBAL_OPTS_WITH_ARG: &[&str] = &["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path"];
fn git_subcommand(seg: &str) -> Option<String> {
let mut found_git = false;
let mut skip_next = false;
for raw in seg.split_whitespace() {
let tok = strip_tok(raw);
if skip_next {
skip_next = false;
continue;
}
if !found_git {
if tok == "git" || tok.ends_with("/git") {
found_git = true;
}
continue;
}
if tok.starts_with("--") && tok.contains('=') {
continue; }
if tok.starts_with('-') {
if GIT_GLOBAL_OPTS_WITH_ARG.contains(&tok.as_str()) {
skip_next = true;
}
continue;
}
return Some(tok);
}
None
}
fn git_segment_targets(seg: &str, want: &str) -> bool {
if git_subcommand(seg).as_deref() == Some(want) {
return true;
}
let mut found_git = false;
for raw in seg.split_whitespace() {
let tok = strip_tok(raw);
if !found_git {
if tok == "git" || tok.ends_with("/git") {
found_git = true;
}
continue;
}
if tok == want {
return true;
}
}
false
}
fn has_adjacent_variable_splice(cmd: &str) -> bool {
if !RE_GIT_OR_RM.is_match(cmd) {
return false;
}
RE_ADJACENT_VAR_SPLICE.is_match(cmd)
}
fn has_brace_expansion(cmd: &str) -> bool {
if !RE_GIT_OR_RM.is_match(cmd) {
return false;
}
RE_BRACE_EXPANSION.is_match(cmd)
}
fn is_git_push_to_main(cmd: &str) -> bool {
split_segments(cmd)
.into_iter()
.any(|seg| git_segment_targets(seg, "push") && RE_PUSH_TO_MAIN.is_match(seg))
}
fn is_git_reset_hard(cmd: &str) -> bool {
split_segments(cmd)
.into_iter()
.any(|seg| git_segment_targets(seg, "reset") && RE_RESET_HARD.is_match(seg))
}
fn split_segments(cmd: &str) -> Vec<&str> {
let mut segs = Vec::new();
let mut start = 0;
let mut i = 0;
while i < cmd.len() {
let rest = &cmd[i..];
if rest.starts_with("&&") || rest.starts_with("||") {
segs.push(&cmd[start..i]);
i += 2;
start = i;
} else if rest.starts_with(';') || rest.starts_with('|') {
segs.push(&cmd[start..i]);
i += 1;
start = i;
} else {
let ch_len = rest.chars().next().map(char::len_utf8).unwrap_or(1);
i += ch_len;
}
}
segs.push(&cmd[start..]);
segs
}
fn short_flag_present(raw_tok: &str, ch: char) -> bool {
let tok = strip_tok(raw_tok);
match tok.strip_prefix('-') {
Some(rest) if !rest.is_empty() && !rest.starts_with('-') && rest.chars().all(|c| c.is_ascii_alphabetic()) => {
rest.chars().any(|c| c.eq_ignore_ascii_case(&ch))
}
_ => false,
}
}
fn is_rm_rf(cmd: &str) -> bool {
for seg in split_segments(cmd) {
let mut in_rm = false;
let (mut has_r, mut has_f) = (false, false);
for raw in seg.split_whitespace() {
let tok = strip_tok(raw);
if !in_rm {
if tok == "rm" || tok.ends_with("/rm") {
in_rm = true;
}
continue;
}
if tok == "--recursive" || tok.starts_with("--recursive=") {
has_r = true;
}
if tok == "--force" || tok.starts_with("--force") {
has_f = true;
}
if short_flag_present(raw, 'r') {
has_r = true;
}
if short_flag_present(raw, 'f') {
has_f = true;
}
}
if has_r && has_f {
return true;
}
}
false
}
fn is_git_force(cmd: &str, subcmd: &str) -> bool {
for seg in split_segments(cmd) {
if !git_segment_targets(seg, subcmd) {
continue;
}
for raw in seg.split_whitespace() {
let tok = strip_tok(raw);
if tok.starts_with("--force") {
return true;
}
if short_flag_present(raw, 'f') {
return true;
}
}
}
false
}
fn deny_json(reason: &str) -> i32 {
let out = serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason
}
});
println!("{out}");
2
}
fn cmd_destructive() -> i32 {
let mut buf = String::new();
if std::io::stdin().read_to_string(&mut buf).is_err() {
return deny_json(
"Blocked: the destructive-command guard could not read the tool-call payload from stdin. \
Failing closed rather than allowing an unverified command through.",
);
}
if buf.trim().is_empty() {
return 0;
}
let event: HookEvent = match serde_json::from_str(&buf) {
Ok(event) => event,
Err(_) => {
return deny_json(
"Blocked: the destructive-command guard received a tool-call payload that isn't valid JSON. \
Failing closed rather than allowing an unverified command through.",
);
}
};
let primary = event.tool_input.get("command").and_then(|v| v.as_str()).unwrap_or("").to_string();
let mut candidates = vec![primary];
if event.tool_name.starts_with("mcp__") {
collect_command_like_strings(&event.tool_input, &mut candidates);
}
for command in candidates.iter().filter(|c| !c.is_empty()) {
if let Some(reason) = check_command(command) {
return deny_json(reason);
}
}
0
}
fn check_command(command: &str) -> Option<&'static str> {
if has_adjacent_variable_splice(command) {
return Some(
"Blocked: command contains a variable reference glued directly between two letters (e.g. word${VAR}word) with no separating whitespace, alongside a git/rm invocation. This guard cannot safely verify commands using this pattern. Run the command without adjacent-letter variable splicing, or ask the human to confirm.",
);
}
if has_brace_expansion(command) {
return Some(
"Blocked: command contains a brace-expansion pattern (e.g. {a,b}) alongside a git/rm invocation. This guard cannot safely verify commands using this pattern — brace expansion generates new arguments before any guard sees them. Run the command without brace expansion, or ask the human to confirm.",
);
}
if is_rm_rf(command) {
return Some(
"Blocked: 'rm -rf' (recursive + force, any flag spelling) is irreversible. Use targeted 'rm' with explicit paths, or ask the human to confirm first.",
);
}
if is_git_force(command, "push") {
return Some(
"Blocked: 'git push --force' (any flag spelling) is not allowed. The orchestrator pushes branches; force-pushing risks overwriting shared history.",
);
}
if is_git_reset_hard(command) {
return Some("Blocked: 'git reset --hard' discards uncommitted work irreversibly. Use 'git stash' or commit before resetting.");
}
if is_git_force(command, "clean") {
return Some("Blocked: 'git clean -f' (any flag spelling) permanently deletes untracked files. Ask the human to confirm before running this.");
}
if is_git_push_to_main(command) {
return Some("Blocked: direct push to main/master. Create a feature branch and open a PR instead.");
}
for (re, reason) in DESTRUCTIVE_PATTERNS_COMPILED.iter() {
if re.is_match(command) {
return Some(reason);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rm_rf_combined_still_blocked() {
assert!(is_rm_rf("rm -rf /tmp/x"));
assert!(is_rm_rf("rm -fr /tmp/x"));
assert!(is_rm_rf("rm -Rf /tmp/x"));
}
#[test]
fn rm_rf_long_form_bypass_fixed() {
assert!(is_rm_rf("rm --recursive --force /tmp/x"));
assert!(is_rm_rf("rm --force --recursive /tmp/x"));
}
#[test]
fn rm_rf_separated_short_flags_bypass_fixed() {
assert!(is_rm_rf("rm -r -f /tmp/x"));
assert!(is_rm_rf("rm -f -r /tmp/x"));
}
#[test]
fn rm_rf_mixed_form_bypass_fixed() {
assert!(is_rm_rf("rm --recursive -f /tmp/x"));
assert!(is_rm_rf("rm -r --force /tmp/x"));
}
#[test]
fn rm_recursive_alone_not_blocked() {
assert!(!is_rm_rf("rm -r /tmp/x"));
assert!(!is_rm_rf("rm -f /tmp/x"));
assert!(!is_rm_rf("rm /tmp/x"));
}
#[test]
fn rm_rf_in_chain_still_caught_unrelated_not_flagged() {
assert!(is_rm_rf("cd /tmp && rm -rf x"));
assert!(is_rm_rf("echo hi; rm -rf /tmp/x"));
assert!(!is_rm_rf("ls -r foo && curl -f url"));
}
#[test]
fn git_push_force_combined_short_flags_bypass_fixed() {
assert!(is_git_force("git push -uf origin main", "push"));
assert!(is_git_force("git push -fu origin main", "push"));
}
#[test]
fn git_push_force_original_forms_still_blocked() {
assert!(is_git_force("git push --force origin main", "push"));
assert!(is_git_force("git push -f origin main", "push"));
assert!(is_git_force("git push --force-with-lease", "push"));
}
#[test]
fn git_push_without_force_allowed() {
assert!(!is_git_force("git push origin main", "push"));
}
#[test]
fn git_clean_force_flag_order_bypass_fixed() {
assert!(is_git_force("git clean -df", "clean"));
assert!(is_git_force("git clean -xdf", "clean"));
}
#[test]
fn git_clean_force_original_forms_still_blocked() {
assert!(is_git_force("git clean -f", "clean"));
assert!(is_git_force("git clean -fd", "clean"));
}
#[test]
fn git_clean_dry_run_allowed() {
assert!(!is_git_force("git clean -n", "clean"));
}
#[test]
fn dash_c_global_opt_no_longer_bypasses_push_force() {
assert!(is_git_force("git -C /tmp/x push --force origin main", "push"));
}
#[test]
fn dash_c_global_opt_no_longer_bypasses_clean_force() {
assert!(is_git_force("git -C /tmp/x clean -f", "clean"));
}
#[test]
fn dash_c_global_opt_no_longer_bypasses_push_to_main() {
assert!(is_git_push_to_main("git -C /tmp/x push origin main"));
}
#[test]
fn dash_c_global_opt_no_longer_bypasses_reset_hard() {
assert!(is_git_reset_hard("git -C /tmp/x reset --hard HEAD~1"));
}
#[test]
fn dash_c_legit_usage_still_allowed() {
assert!(!is_git_force("git -C /tmp/x status", "push"));
assert!(!is_git_push_to_main("git -C /tmp/x log --oneline -5"));
}
#[test]
fn unlisted_global_opt_no_longer_bypasses_push_force() {
assert!(is_git_force("git --super-prefix /tmp/x push --force origin main", "push"));
}
#[test]
fn unlisted_global_opt_no_longer_bypasses_clean_force() {
assert!(is_git_force("git --super-prefix /tmp/x clean -fd", "clean"));
}
#[test]
fn quoted_subcommand_token_no_longer_bypasses() {
assert!(is_git_force(r#"git "push" --force origin main"#, "push"));
}
#[test]
fn backslash_escaped_subcommand_token_no_longer_bypasses() {
assert!(is_git_force(r"git \push --force origin main", "push"));
}
#[test]
fn quoted_force_flag_still_blocked() {
assert!(is_git_force(r#"git push "--force" origin feature-branch"#, "push"));
}
#[test]
fn quoted_rm_flag_token_no_longer_bypasses() {
assert!(is_rm_rf(r#"rm "-rf" /tmp/x"#));
}
#[test]
fn ifs_spliced_subcommand_denied_outright() {
assert!(has_adjacent_variable_splice("git${IFS}push --force origin main"));
}
#[test]
fn ifs_spliced_rm_flag_denied_outright() {
assert!(has_adjacent_variable_splice("rm${IFS}-rf /tmp/x"));
}
#[test]
fn env_var_prefixed_git_command_not_flagged_as_splice() {
assert!(!has_adjacent_variable_splice("GIT_AUTHOR_NAME=x git commit -m test"));
}
#[test]
fn unrelated_adjacent_splice_without_git_or_rm_allowed() {
assert!(!has_adjacent_variable_splice("echo a${b}c"));
}
#[test]
fn ansi_c_quoted_subcommand_no_longer_bypasses() {
assert!(is_git_force("git $'push' --force origin main", "push"));
}
#[test]
fn ansi_c_quoted_force_flag_still_blocked() {
assert!(is_git_force("git push $'--force' origin feature-branch", "push"));
}
#[test]
fn brace_expansion_alongside_rm_denied_outright() {
assert!(has_brace_expansion("rm -{rf,} /tmp/x"));
}
#[test]
fn unrelated_brace_expansion_without_git_or_rm_allowed() {
assert!(!has_brace_expansion("echo file.{js,ts}"));
}
#[test]
fn reset_hard_still_blocked_without_global_opt() {
assert!(is_git_reset_hard("git reset --hard"));
assert!(!is_git_reset_hard("git reset"));
assert!(!is_git_reset_hard("git reset --soft HEAD~1"));
}
#[test]
fn push_to_main_still_blocked_without_global_opt() {
assert!(is_git_push_to_main("git push origin main"));
assert!(is_git_push_to_main("git push master"));
assert!(!is_git_push_to_main("git push origin feature-branch"));
}
fn does_not_panic(f: impl FnOnce() -> bool + std::panic::UnwindSafe) -> bool {
std::panic::catch_unwind(f).unwrap_or_else(|_| panic!("guard function panicked"))
}
#[test]
fn vietnamese_text_in_benign_command_does_not_panic() {
assert!(!does_not_panic(|| is_rm_rf("echo \"xin chào thế giới\"")));
assert!(!does_not_panic(|| is_git_force("git commit -m \"sửa lỗi\"", "push")));
}
#[test]
fn em_dash_in_git_commit_message_does_not_panic() {
assert!(does_not_panic(|| is_git_push_to_main(
"git commit -m \"note — done\" && git push origin main"
)));
}
#[test]
fn destructive_command_with_vietnamese_text_still_denied() {
assert!(does_not_panic(|| is_rm_rf(
"rm -rf /tmp/x # xóa thư mục tạm"
)));
}
#[test]
fn cjk_and_emoji_in_command_does_not_panic() {
assert!(!does_not_panic(|| is_rm_rf("echo \"你好 🎉\"")));
}
#[test]
fn malformed_json_is_rejected_not_silently_defaulted() {
let result: Result<HookEvent, _> = serde_json::from_str("not valid json{{{");
assert!(result.is_err());
}
#[test]
fn empty_json_object_parses_to_empty_command() {
let event: HookEvent = serde_json::from_str("{}").unwrap();
assert!(event.tool_input.get("command").is_none());
assert_eq!(event.tool_name, "");
}
#[test]
fn hook_event_parses_tool_name_and_arbitrary_tool_input_shape() {
let event: HookEvent =
serde_json::from_str(r#"{"tool_name":"mcp__x__y","tool_input":{"cmd":"ls"}}"#).unwrap();
assert_eq!(event.tool_name, "mcp__x__y");
assert_eq!(event.tool_input.get("cmd").and_then(|v| v.as_str()), Some("ls"));
}
#[test]
fn tokenize_key_splits_snake_case() {
assert_eq!(tokenize_key("shell_command"), vec!["shell", "command"]);
}
#[test]
fn tokenize_key_splits_camel_case() {
assert_eq!(tokenize_key("executeScript"), vec!["execute", "script"]);
assert_eq!(tokenize_key("shellCommand"), vec!["shell", "command"]);
}
#[test]
fn tokenize_key_single_word_stays_one_token() {
assert_eq!(tokenize_key("description"), vec!["description"]);
assert_eq!(tokenize_key("command"), vec!["command"]);
}
#[test]
fn is_command_like_key_matches_exact_tokens_only() {
assert!(is_command_like_key("command"));
assert!(is_command_like_key("cmd"));
assert!(is_command_like_key("shell_command"));
assert!(is_command_like_key("executeScript"));
assert!(is_command_like_key("params_script"));
}
#[test]
fn is_command_like_key_rejects_substring_false_positives() {
assert!(!is_command_like_key("description"));
assert!(!is_command_like_key("content"));
assert!(!is_command_like_key("message"));
assert!(!is_command_like_key("prompt"));
assert!(!is_command_like_key("recommendation")); }
#[test]
fn collect_command_like_strings_finds_nested_value_ignores_sibling_prose() {
let v = serde_json::json!({
"description": "never run rm -rf in prod",
"params": { "command": "rm -rf /tmp/x" }
});
let mut out = Vec::new();
collect_command_like_strings(&v, &mut out);
assert_eq!(out, vec!["rm -rf /tmp/x".to_string()]);
}
#[test]
fn collect_command_like_strings_finds_camel_case_key_at_top_level() {
let v = serde_json::json!({ "shellCommand": "git push --force origin main" });
let mut out = Vec::new();
collect_command_like_strings(&v, &mut out);
assert_eq!(out, vec!["git push --force origin main".to_string()]);
}
#[test]
fn collect_command_like_strings_descends_into_arrays() {
let v = serde_json::json!({ "steps": [ { "cmd": "rm -rf /tmp/y" } ] });
let mut out = Vec::new();
collect_command_like_strings(&v, &mut out);
assert_eq!(out, vec!["rm -rf /tmp/y".to_string()]);
}
#[test]
fn check_command_denies_rm_rf_regardless_of_source() {
assert!(check_command("rm -rf /tmp/x").is_some());
}
#[test]
fn tokenize_key_splits_acronym_to_word_boundary() {
assert_eq!(tokenize_key("SQLCommand"), vec!["sql", "command"]);
assert_eq!(tokenize_key("URLExecScript"), vec!["url", "exec", "script"]);
}
#[test]
fn tokenize_key_ordinary_camel_case_unaffected_by_acronym_fix() {
assert_eq!(tokenize_key("shellCommand"), vec!["shell", "command"]);
assert_eq!(tokenize_key("executeScript"), vec!["execute", "script"]);
}
#[test]
fn is_command_like_key_matches_acronym_prefixed_key() {
assert!(is_command_like_key("SQLCommand"));
}
#[test]
fn collect_command_like_strings_finds_value_under_acronym_prefixed_key() {
let v = serde_json::json!({ "SQLCommand": "DROP TABLE users;" });
let mut out = Vec::new();
collect_command_like_strings(&v, &mut out);
assert_eq!(out, vec!["DROP TABLE users;".to_string()]);
}
#[test]
fn collect_command_like_strings_extracts_array_of_strings_under_plural_key() {
let v = serde_json::json!({ "commands": ["rm -rf /tmp/x", "echo ok"] });
let mut out = Vec::new();
collect_command_like_strings(&v, &mut out);
assert_eq!(out, vec!["rm -rf /tmp/x".to_string(), "echo ok".to_string()]);
}
#[test]
fn collect_command_like_strings_array_of_objects_still_works_after_array_fix() {
let v = serde_json::json!({ "steps": [ { "cmd": "rm -rf /tmp/y" } ] });
let mut out = Vec::new();
collect_command_like_strings(&v, &mut out);
assert_eq!(out, vec!["rm -rf /tmp/y".to_string()]);
}
#[test]
fn collect_command_like_strings_respects_max_depth() {
let mut v = serde_json::json!({ "command": "rm -rf /tmp/deep" });
for _ in 0..(MAX_COLLECT_DEPTH + 10) {
v = serde_json::json!({ "wrapper": v });
}
let mut out = Vec::new();
collect_command_like_strings(&v, &mut out);
assert!(out.is_empty(), "value past MAX_COLLECT_DEPTH must not be collected");
}
#[test]
fn collect_command_like_strings_within_max_depth_still_found() {
let mut v = serde_json::json!({ "command": "rm -rf /tmp/shallow" });
for _ in 0..(MAX_COLLECT_DEPTH - 5) {
v = serde_json::json!({ "wrapper": v });
}
let mut out = Vec::new();
collect_command_like_strings(&v, &mut out);
assert_eq!(out, vec!["rm -rf /tmp/shallow".to_string()]);
}
}