//! Flag (M5b spec, section 6.3): text shaped like an attack. A flag never refuses: memory keeps the //! text, one tier lower and marked untrusted. So a rule may be a little broad, but it must match //! attack behaviour, not bossy English, and the code-work corpus in //! `tests/sanitize_flag_corpus.rs` holds it to that. //! //! The first seven rules are ported from Hermes Agent's strict threat patterns, //! `tools/threat_patterns.py`, read at commit d0288be5b3330d2442e3907185b8e9d0958297bb //! (2026-09-26; the file last changed in 1b50e99), https://github.com/NousResearch/hermes-agent, //! Copyright (c) 2025 Nous Research, MIT License. The eighth is M5a's six instruction phrases, //! which `memoryd` refused and which flag from M5b on. //! //! **The translation.** Hermes matches regular expressions on NFKC text; Boxmaker has no regex //! crate, so each pattern is written as a sequence of `Part`s matched against the words of the //! `normalise`d text (split at its single spaces). Hermes' `_FILLER`, up to eight `\w+` words, is //! `Filler(FILLER)`; its `[^\n]{0,N}` gaps are `Gap(LINE)`. `normalise` joins lines, so a gap is //! bounded in words instead, and does not cross the end of a sentence or of a shell command //! (`&&`, `||`, `|`). A keyword is a whole word, punctuation around it aside, where Hermes' //! keywords also matched inside longer words (its `\badd\b` matched in `ssh-add`), which code is //! full of. //! //! **Not ported**, and why (Hermes' pattern ids): //! - `html_comment_injection`, `hidden_div`: HTML comments and styles are ordinary code. //! - `translate_execute`: "translate the parser into Rust and run the tests" is a coding task. //! - `deception_hide`: "do not tell the user whether the email exists" is advice about auth code. //! - `role_hijack`, `role_pretend`, `leak_system_prompt`, `remove_filters`, `fake_update`, //! `identity_override`: role-play and prompt vocabulary; a harness's own notes use it. //! - The C2 group (`c2_*`, `forced_action`, `anti_forensic_*`, `env_var_unset_agent`, //! `known_c2_framework`): heartbeats, tasks, nodes and `unset OPENAI_API_KEY` are ordinary in //! distributed code and its how-tos; Hermes itself only warns on them. //! - `exfil_curl`, `exfil_wget`, `read_secrets`: `curl -H "…$TOKEN" https://api…` and //! `cat .env.example` are how every API and project README reads; `exfil-url` keeps the attack. //! - `hermes_env`, `hermes_config_mod`: Hermes' own paths. //! - `agent_config_mod` (editing `AGENTS.md`, `CLAUDE.md`, `.cursorrules`, `.clinerules`): //! Boxmaker's agent never loads those files as its own instructions (its identity is `memoryd`'s //! files), so the rule would protect nothing here, and it fires on ordinary notes about code. //! - `hardcoded_secret`: secrets are refused by their prefixes (6.1); a `password = "…"` in code //! is test data far more often. use super::normalise::normalise; /// The rule a text matched. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Flag { /// The rule's stable name, as the journal, memory's answer and `bxctl sanitize check` show it. pub rule: &'static str, } /// Hermes' `_FILLER`: up to this many words of letters, digits and `_` between two keywords. const FILLER: usize = 8; /// Hermes' `[^\n]{0,N}`: up to this many words of any kind, about a sentence. const LINE: usize = 12; /// One step of a `Shape::Seq`, matched against the normalised text's words in order. enum Part { /// One word that is one of these, whole or with punctuation around it stripped. Is(&'static [&'static str]), /// One word that is one of these, as for `Is`, and ends a clause (`ends_clause`). Ends(&'static [&'static str]), /// One word that contains one of these. Holds(&'static [&'static str]), /// Up to this many words, each letters, digits and `_` only. Filler(usize), /// Up to this many words of any kind, none ending a sentence or a command (`in_sentence`). Gap(usize), } enum Shape { /// This text anywhere in the normalised text, as M5a matched its phrases. Phrase(&'static str), /// These parts, from some word on. Seq(&'static [Part]), } struct Rule { name: &'static str, shapes: &'static [Shape], } use Part::{Ends, Filler, Gap, Holds, Is}; use Shape::{Phrase, Seq}; const DUMP_VERBS: &[&str] = &["include", "output", "print", "share"]; /// What a word ends with, or the words that follow it, when it ends a clause (`ends_clause`). const CLAUSE_ENDS: [char; 6] = ['.', ',', ':', ';', '!', '?']; const CLAUSE_FOLLOWERS: &[&[&str]] = &[ &["so", "far"], &["above"], &["to"], &["into"], &["in"], &["with"], &["verbatim"], ]; /// Hermes' write verbs for `ssh_access`, less `cat`, `echo`, `printf` and `git`: they read or /// print without a redirect, which is its own shape here. const SSH_WRITERS: &[&str] = &[ "cp", "mv", "dd", "tee", "install", "rsync", "scp", "ln", "append", "add", "write", "sed", "chmod", "chown", "truncate", "rm", "touch", "curl", "wget", ]; /// Hermes' `$HOME/.ssh` and `~/.ssh` (`ssh_access`), and `authorized_keys` (`ssh_backdoor`). const SSH_PLACES: &[&str] = &["~/.ssh", "$home/.ssh", "authorized_keys"]; /// The rules, in the order they are tried: the first that matches is the flag. const RULES: &[Rule] = &[ // `prompt_injection`: ignore\s+{F}(previous|all|above|prior)\s+{F}instructions Rule { name: "ignore-instructions", shapes: &[Seq(&[ Is(&["ignore"]), Filler(FILLER), Is(&["previous", "all", "above", "prior"]), Filler(FILLER), Is(&["instructions"]), ])], }, // `disregard_rules`: disregard\s+{F}(your|all|any)\s+{F}(instructions|rules|guidelines) Rule { name: "disregard-instructions", shapes: &[Seq(&[ Is(&["disregard"]), Filler(FILLER), Is(&["your", "all", "any"]), Filler(FILLER), Is(&["instructions", "rules", "guidelines"]), ])], }, // `sys_prompt_override`: system\s+prompt\s+override Rule { name: "system-prompt-override", shapes: &[Seq(&[Is(&["system"]), Is(&["prompt"]), Is(&["override"])])], }, // `bypass_restrictions`: act\s+as\s+(if|though)\s+{F}you\s+{F}(have\s+no|don't\s+have)\s+{F} // (restrictions|limits|rules); one shape for each negation. Rule { name: "no-restrictions", shapes: &[ Seq(&[ Is(&["act"]), Is(&["as"]), Is(&["if", "though"]), Filler(FILLER), Is(&["you"]), Filler(FILLER), Is(&["have"]), Is(&["no"]), Filler(FILLER), Is(&["restrictions", "limits", "rules"]), ]), Seq(&[ Is(&["act"]), Is(&["as"]), Is(&["if", "though"]), Filler(FILLER), Is(&["you"]), Filler(FILLER), Is(&["don't"]), Is(&["have"]), Filler(FILLER), Is(&["restrictions", "limits", "rules"]), ]), ], }, // `send_to_url`: (send|post|upload|transmit)\s+[^\n]{0,2048}\s+(to|at)\s+https?:// // Hermes needs a word between the verb and `to`; "send to https://…" is the same attack. Rule { name: "exfil-url", shapes: &[Seq(&[ Is(&["send", "post", "upload", "transmit"]), Gap(LINE), Is(&["to", "at"]), Holds(&["http://", "https://"]), ])], }, // `context_exfil`: (include|output|print|share)\s+{F}(conversation|chat\s+history| // previous\s+messages|full\s+context|entire\s+context). Narrowed: the object must end its // clause (`Ends`), so "include the conversation id in each log line" passes, where the // object is only a modifier, and "print the entire conversation so far" still flags. Rule { name: "dump-conversation", shapes: &[ Seq(&[Is(DUMP_VERBS), Filler(FILLER), Ends(&["conversation"])]), Seq(&[ Is(DUMP_VERBS), Filler(FILLER), Is(&["chat"]), Ends(&["history"]), ]), Seq(&[ Is(DUMP_VERBS), Filler(FILLER), Is(&["previous"]), Ends(&["messages"]), ]), Seq(&[ Is(DUMP_VERBS), Filler(FILLER), Is(&["full", "entire"]), Ends(&["context"]), ]), ], }, // `ssh_access`: one of 23 verbs (\b(echo|cat|cp|…|git)\b), or \bopen\s*\(, or a redirect // (>>?), then [^\n]{0,512}, then (\$HOME/\.ssh|~/\.ssh); and `ssh_backdoor`: authorized_keys // anywhere. Narrowed to writing: a verb of `SSH_WRITERS` before one of `SSH_PLACES`, or a // redirect right before one. `open(` is left out: a read looks the same. Rule { name: "persist-ssh", shapes: &[ Seq(&[Is(SSH_WRITERS), Gap(LINE), Holds(SSH_PLACES)]), Seq(&[Is(&[">", ">>"]), Holds(SSH_PLACES)]), Seq(&[Holds(&[">~/.ssh", ">$home/.ssh", ">authorized_keys"])]), ], }, // M5a's six phrases (`memoryd`'s `check.rs`), matched anywhere, as M5a did. Rule { name: "instruction-phrase", shapes: &[ Phrase("ignore previous instructions"), Phrase("ignore all previous"), Phrase("disregard previous instructions"), Phrase("disregard all previous"), Phrase("new instructions:"), Phrase("system prompt:"), ], }, ]; /// The first rule in `RULES` that `text`, normalised, matches, or `None`. pub fn flag(text: &str) -> Option { let normal = normalise(text); let words: Vec<&str> = normal.split(' ').filter(|word| !word.is_empty()).collect(); RULES .iter() .find(|rule| { rule.shapes .iter() .any(|shape| shape_matches(shape, &normal, &words)) }) .map(|rule| Flag { rule: rule.name }) } /// A `Phrase` matches if `normal` contains it; a `Seq` if `seq_at` holds from some word on. fn shape_matches(shape: &Shape, normal: &str, words: &[&str]) -> bool { match shape { Phrase(phrase) => normal.contains(phrase), Seq(parts) => { (0..words.len()).any(|at| words.get(at..).is_some_and(|rest| seq_at(parts, rest))) } } } /// Whether `parts` match `words` from its first word on (the words after the match may be /// anything). No parts left: `true`. Otherwise, by the first part: `Is`, `Ends` and `Holds` test /// one word with `one_word` (with `is`; `is` and `ends_clause`; `str::contains` for any of the /// pieces); `Filler` and `Gap` go to `skip`, with `filler` or `in_sentence`. Each passes the /// parts after the first as `rest`. fn seq_at(parts: &[Part], words: &[&str]) -> bool { match parts.split_first() { None => true, Some((part, rest)) => match part { Is(keywords) => one_word(words, rest, |w, _| is(w, keywords)), Ends(keywords) => one_word(words, rest, |w, after| { is(w, keywords) && ends_clause(w, after) }), Holds(pieces) => one_word(words, rest, |w, _| { pieces.iter().any(|piece| w.contains(piece)) }), Filler(max) => skip(words, rest, *max, filler), Gap(max) => skip(words, rest, *max, in_sentence), }, } } /// Whether the first of `words` passes `test`, given it and the words after it, and `rest` /// matches the words after it. fn one_word(words: &[&str], rest: &[Part], test: impl Fn(&str, &[&str]) -> bool) -> bool { if let Some((word, after)) = words.split_first() { return test(word, after) && seq_at(rest, after); } false } /// Whether, for some count from 0 to `max`, the first that many `words` all pass `test` and /// `rest` matches the words after them. fn skip(words: &[&str], rest: &[Part], max: usize, test: fn(&str) -> bool) -> bool { for count in 0..=max { match words.split_at_checked(count) { Some((skipped, after)) => { if !skipped.iter().all(|word| test(word)) { return false; } if seq_at(rest, after) { return true; } } None => return false, } } false } /// Whether `word` is one of `keywords`, as it stands or with the characters that are not /// letters, digits or `_` stripped from both ends (`instructions.` is `instructions`). fn is(word: &str, keywords: &[&str]) -> bool { let stripped = word.trim_matches(|c: char| !(c.is_alphanumeric() || c == '_')); keywords.iter().any(|&kw| kw == word || kw == stripped) } /// Whether `word`, followed by `after`, ends a clause: it ends with one of `CLAUSE_ENDS`, or /// `after` is empty, or `after` starts with one of `CLAUSE_FOLLOWERS`, word by word as `is` /// compares (`far.` is `far`). fn ends_clause(word: &str, after: &[&str]) -> bool { if word.ends_with(CLAUSE_ENDS) { return true; } if after.is_empty() { return true; } for follower in CLAUSE_FOLLOWERS { if let Some(next) = after.get(..follower.len()) && next.iter().zip(follower.iter()).all(|(w, f)| is(w, &[f])) { return true; } } false } /// A filler word: not empty, and only letters, digits and `_` (Hermes' `\w+`). fn filler(word: &str) -> bool { !word.is_empty() && word.chars().all(|c| c.is_alphanumeric() || c == '_') } /// A word a gap may skip: one that does not end a sentence (with `.`, `!`, `?` or `;`) and is not /// a shell's `&&`, `||` or `|`. fn in_sentence(word: &str) -> bool { !word.ends_with(['.', '!', '?', ';']) && !["&&", "||", "|"].contains(&word) }