//! Subagent-level tool gating (mirrors Harness checks). //! //! Flow: always blocks dangerous patterns — path traversal, stub/denial/ //! assumption language, bash exfiltration, destructive commands, sensitive //! path reads — regardless of the allowed-tools list. Tools that are not //! risky only get the basic allowlist check. //! //! Security: subagent tool gating mirrors the main agent's `Guard` checks //! (path traversal, reason validation, stub/denial/assumption scanning, //! bash exfiltration and destructive-pattern detection) so that subagents //! are not a weaker link than the main agent. use crate::app::guard::patterns::{ ASSUMPTION_PATTERNS, DENIAL_PATTERNS, EXFIL_PATTERNS, MIN_REASON_LEN, SENSITIVE_PATH_PATTERNS, STUB_PATTERNS, }; /// Gate a tool call in the subagent context. Returns `Some(block_reason)` if /// the call should be blocked, `None` to allow. pub(crate) fn gate_subagent_tool_call( tool_name: &str, args: &serde_json::Value, ) -> Option { // File-mutating tools: write / edit / delete if matches!(tool_name, "write" | "edit" | "delete") { if let Some(path) = args.get("path").and_then(|v| v.as_str()) { if path.contains("..") { return Some("path traversal detected in 'path' argument".to_string()); } } } // write / edit / delete require a non-trivial `reason` if matches!(tool_name, "write" | "edit" | "delete") { let reason = args.get("reason").and_then(|v| v.as_str()).unwrap_or(""); if reason.trim().len() < MIN_REASON_LEN { return Some(format!( "{tool_name} requires a non-trivial 'reason' (>= {MIN_REASON_LEN} chars) explaining why", )); } } // write / edit content must not contain stubs, denial, or assumption language if matches!(tool_name, "write" | "edit") { let content = match tool_name { "write" => args.get("content").and_then(|v| v.as_str()).unwrap_or(""), "edit" => { let old = args.get("old").and_then(|v| v.as_str()).unwrap_or(""); let new = args.get("new").and_then(|v| v.as_str()).unwrap_or(""); // For edits, scanning old+new together catches stubs in both return if contains_any(old, STUB_PATTERNS) || contains_any(new, STUB_PATTERNS) { Some( "content contains stub/placeholder pattern; production code must be fully implemented" .to_string(), ) } else if contains_any(new, DENIAL_PATTERNS) { Some( "content contains denial/punt pattern; implement properly instead of skipping" .to_string(), ) } else if contains_any(new, ASSUMPTION_PATTERNS) { Some( "content contains assumption pattern; verify against data instead of guessing" .to_string(), ) } else { return None; }; } _ => "", }; if contains_any(content, STUB_PATTERNS) { return Some( "content contains stub/placeholder pattern; production code must be fully implemented" .to_string(), ); } if contains_any(content, DENIAL_PATTERNS) { return Some( "content contains denial/punt pattern; implement properly instead of skipping" .to_string(), ); } if contains_any(content, ASSUMPTION_PATTERNS) { return Some( "content contains assumption pattern; verify against data instead of guessing" .to_string(), ); } } // Bash: exfiltration, sensitive paths, destructive commands if tool_name == "bash" { let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or(""); if cmd.contains("..") { return Some("path traversal detected in bash command".to_string()); } // Only check exfiltration for non-standard commands let is_standard = cmd.trim_start().starts_with("cargo") || cmd.trim_start().starts_with("rustc") || cmd.trim_start().starts_with("git ") || cmd.trim_start().starts_with("ls") || cmd.trim_start().starts_with("pwd") || cmd.trim_start().starts_with("echo") || cmd.trim_start().starts_with("cat") || cmd.trim_start().starts_with("find") || cmd.trim_start().starts_with("grep") || cmd.trim_start().starts_with("test"); if !is_standard { for pat in EXFIL_PATTERNS { if cmd.contains(pat) { return Some(format!( "potential data-exfiltration command blocked (matched '{pat}')" )); } } } for pat in SENSITIVE_PATH_PATTERNS { if cmd.contains(pat) { return Some(format!("refused to read/write sensitive path '{pat}'")); } } let dangerous = [ "rm -rf /", "rm -rf --no-preserve-root", "rm -rf ~", "rm -fr /", "mkfs.", "dd if=", ":(){", "> /dev/sda", "chmod -R 000 /", "shutdown ", "poweroff ", "reboot ", "halt ", ]; for pat in &dangerous { if cmd.contains(pat) { return Some(format!("destructive command pattern blocked: {pat}")); } } if contains_any(cmd, STUB_PATTERNS) { return Some("bash command contains stub pattern".to_string()); } } // git_operator: require reason if tool_name == "git_operator" { let reason = args.get("reason").and_then(|v| v.as_str()).unwrap_or(""); if reason.trim().len() < MIN_REASON_LEN { return Some("git_operator requires a non-trivial 'reason' (>= 8 chars)".to_string()); } } None } /// Check if `text` matches any pattern (case-insensitive substring). pub(crate) fn contains_any(text: &str, patterns: &[&str]) -> bool { let lower = text.to_lowercase(); patterns.iter().any(|p| lower.contains(&p.to_lowercase())) }