use std::sync::LazyLock;
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 RE_INLINE_SCRIPT_INTERPRETER: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"(?i)\b(python3?|node|ruby|perl|bash|sh|zsh)\b[^|;&]*(-c|-e|--eval)\b")
.unwrap()
});
static RE_INLINE_RM_RF: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"(?i)\brm\b[^|;&]*(-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*r[a-zA-Z]*|--recursive|--force)").unwrap()
});
static RE_INLINE_GIT_FORCE_PUSH: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(
r"(?i)\bgit\b[^|;&]*\bpush\b[^|;&]*--force|\bgit\b[^|;&]*--force[^|;&]*\bpush\b",
)
.unwrap()
});
static RE_INLINE_GIT_RESET_HARD: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"(?i)\bgit\b[^|;&]*\breset\b[^|;&]*--hard").unwrap());
static RE_INLINE_GIT_CLEAN_FORCE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"(?i)\bgit\b[^|;&]*\bclean\b[^|;&]*(-[a-zA-Z]*f[a-zA-Z]*|--force)").unwrap()
});
static RE_INLINE_SQL_DESTRUCTIVE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"(?i)\b(DROP\s+(TABLE|DATABASE|SCHEMA)|TRUNCATE\s+TABLE)\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
}
pub(crate) 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)
}
pub(crate) fn has_brace_expansion(cmd: &str) -> bool {
if !RE_GIT_OR_RM.is_match(cmd) {
return false;
}
RE_BRACE_EXPANSION.is_match(cmd)
}
pub(crate) 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))
}
pub(crate) 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))
}
pub(crate) 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,
}
}
pub(crate) 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
}
pub(crate) 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 has_inline_script_bypass(command: &str) -> bool {
if !RE_INLINE_SCRIPT_INTERPRETER.is_match(command) {
return false;
}
RE_INLINE_RM_RF.is_match(command)
|| RE_INLINE_SQL_DESTRUCTIVE.is_match(command)
|| RE_INLINE_GIT_FORCE_PUSH.is_match(command)
|| RE_INLINE_GIT_RESET_HARD.is_match(command)
|| RE_INLINE_GIT_CLEAN_FORCE.is_match(command)
}
pub 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);
}
}
if has_inline_script_bypass(command) {
return Some(
"Blocked: command invokes an interpreter (python/node/ruby/perl/bash/sh/zsh) with an inline script (-c/-e/--eval) whose content appears to contain a destructive pattern (rm -rf, DROP TABLE/TRUNCATE, git push --force, git reset --hard, or git clean -f). This guard cannot safely verify commands embedded inside interpreter scripts. Run the destructive operation directly (not wrapped in an inline script), or ask the human to confirm.",
);
}
None
}