bxcore: sanitize::flag, text shaped like an attack
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
//! 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<Flag> {
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user