Some visitors will type things they shouldn’t into a chatbot: a card number, a Social Security number, a bank account. This snippet catches those on your server, before anything is sent to the AI, and either stops the message or hides the sensitive parts.
What it catches
- Card numbers, 13 to 19 digits, spaces or dashes allowed. Each one must pass the Luhn check, so order numbers and dates are left alone.
- Social Security numbers written like
123-45-6789or123 45 6789(ranges that are never issued are skipped). - Bank routing numbers (US ABA), checked with their prefix and checksum.
- Account and member numbers written after a keyword, like account 12345678, acct #: 5566-7788 or my SSN is 123456789.
- Emails and phone numbers, off by default, each with its own switch.
Block or mask
Set MWAI_PII_MODE at the top of the snippet:
- block: the message is not sent, and the visitor sees a friendly notice (you can change its text with
MWAI_PII_BLOCK_MESSAGE). - mask: the message is sent, with every sensitive value replaced by
[REDACTED]. The AI never sees the real number.
In both modes, earlier messages in the conversation are masked too. They are never blocked: a blocked message stays in the visitor’s chat history, and blocking on it would lock the chat for good.
Why it runs so early
The snippet hooks mwai_context_search at priority 1, and mwai_ai_query as well. If your chatbot uses a Knowledge Base (embeddings), AI Engine searches it before mwai_ai_query runs, and that search sends the visitor’s message to your embeddings provider. Hooking only mwai_ai_query would be too late for those chatbots. Running twice is harmless: once masked, there is nothing left to mask.
Install
Paste it into Code Engine ↗ as a PHP snippet that runs everywhere, or into your theme’s functions.php. It works with chatbots and AI Forms, and needs AI Engine 3.6 or newer.
<?php
/**
* AI Engine: Sensitive Data Guard
*
* Checks what a visitor types into a chatbot or an AI Form before anything leaves
* your server, and either blocks the message or replaces the sensitive parts
* with [REDACTED]. Requires AI Engine 3.6 or newer.
*/
// 'block': refuse the message and show MWAI_PII_BLOCK_MESSAGE to the visitor.
// 'mask': send it with the sensitive parts replaced by [REDACTED].
define( 'MWAI_PII_MODE', 'block' );
define( 'MWAI_PII_CHECK_EMAILS', false );
define( 'MWAI_PII_CHECK_PHONES', false );
define( 'MWAI_PII_BLOCK_MESSAGE', "For your security, please don't share card numbers, Social Security numbers or account numbers here. Your message was not sent." );
// Returns the text with every sensitive value replaced by [REDACTED].
function mwai_pii_mask( $text ) {
if ( !is_string( $text ) || $text === '' ) {
return $text;
}
// Card numbers: 13 to 19 digits, spaces or dashes allowed, must pass the Luhn check
// (this is what keeps order numbers and dates from being flagged).
$text = preg_replace_callback( '/(?<![\d-])\d(?:[ -]?\d){12,18}(?![\d-])/', function ( $m ) {
$digits = preg_replace( '/\D/', '', $m[0] );
return mwai_pii_luhn( $digits ) ? '[REDACTED]' : $m[0];
}, $text );
// SSNs written 123-45-6789 or 123 45 6789, skipping ranges that are never issued.
$text = preg_replace( '/\b(?!000|666|9\d\d)\d{3}([- ])(?!00)\d{2}\1(?!0000)\d{4}\b/', '[REDACTED]', $text );
// Any bare 9-digit number that is a valid ABA routing number.
$text = preg_replace_callback( '/(?<!\d)\d{9}(?!\d)/', function ( $m ) {
return mwai_pii_aba( $m[0] ) ? '[REDACTED]' : $m[0];
}, $text );
// Numbers following a keyword: "account 12345678", "acct #: 0012-3456", "my SSN is 123456789".
$keywords = 'account|acct|a\/c|member(?:ship)?|routing|aba|ssn|social(?: security)?|tax id|tin|card|pin';
$text = preg_replace(
'/\b(' . $keywords . ')\b(\s*(?:number|no\.?|num|#)?\s*(?:is|:|=)?\s*#?\s*)\d[\d -]{2,20}\d/i',
'$1$2[REDACTED]',
$text
);
if ( MWAI_PII_CHECK_EMAILS ) {
$text = preg_replace( '/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i', '[REDACTED]', $text );
}
if ( MWAI_PII_CHECK_PHONES ) {
$text = preg_replace( '/(?<![\w])(?:\+?1[ .-]?)?\(?[2-9]\d{2}\)?[ .-]?\d{3}[ .-]?\d{4}(?![\w])/', '[REDACTED]', $text );
}
return $text;
}
function mwai_pii_luhn( $digits ) {
$sum = 0;
$double = false;
for ( $i = strlen( $digits ) - 1; $i >= 0; $i-- ) {
$d = (int) $digits[$i];
if ( $double ) {
$d *= 2;
if ( $d > 9 ) {
$d -= 9;
}
}
$sum += $d;
$double = !$double;
}
return $sum % 10 === 0;
}
// Valid ABA routing numbers start with 00-12, 21-32, 61-72 or 80, and pass a checksum.
function mwai_pii_aba( $digits ) {
$prefix = (int) substr( $digits, 0, 2 );
$validPrefix = $prefix <= 12 || ( $prefix >= 21 && $prefix <= 32 ) || ( $prefix >= 61 && $prefix <= 72 ) || $prefix === 80;
if ( !$validPrefix ) {
return false;
}
$w = [ 3, 7, 1, 3, 7, 1, 3, 7, 1 ];
$sum = 0;
for ( $i = 0; $i < 9; $i++ ) {
$sum += (int) $digits[$i] * $w[$i];
}
return $sum % 10 === 0 && $digits !== '000000000';
}
// Masks a history message whatever its shape (plain string, or a list of parts).
function mwai_pii_mask_content( $content ) {
if ( is_string( $content ) ) {
return mwai_pii_mask( $content );
}
if ( is_array( $content ) ) {
foreach ( $content as $key => $value ) {
$content[$key] = ( $key === 'text' || is_array( $value ) ) ? mwai_pii_mask_content( $value ) : $value;
}
}
return $content;
}
function mwai_pii_guard( $query ) {
if ( !is_object( $query ) || !isset( $query->message ) || $query instanceof Meow_MWAI_Query_Embed ) {
return;
}
// The new message: blocked or masked, depending on the mode.
$masked = mwai_pii_mask( $query->message );
if ( $masked !== $query->message ) {
if ( MWAI_PII_MODE === 'block' ) {
// A refusal is shown to the visitor as is (a plain Exception would become a generic error).
throw new Meow_MWAI_RefusedException( MWAI_PII_BLOCK_MESSAGE, 'pii' );
}
$query->set_message( $masked );
}
// The conversation history is always masked, never blocked: the visitor's browser
// keeps a blocked message in its history, and blocking on it would lock the chat.
foreach ( $query->messages as $i => $message ) {
if ( isset( $message['content'] ) ) {
$query->messages[$i]['content'] = mwai_pii_mask_content( $message['content'] );
}
}
}
// Runs before the Knowledge Base (embeddings) search, which sends the message to the
// embeddings provider. Priority 1 so it runs before AI Engine's own search.
add_filter( 'mwai_context_search', function ( $context, $query ) {
mwai_pii_guard( $query );
return $context;
}, 1, 2 );
// Runs right before the query is sent to the AI (chatbots, forms, and bots without a
// Knowledge Base). Running twice is harmless: masked text has nothing left to mask.
add_filter( 'mwai_ai_query', function ( $query ) {
mwai_pii_guard( $query );
return $query;
}, 1 );
Good to know
- Pattern matching lowers the risk, it doesn’t remove it. It can miss numbers written in words or split across messages, and it can sometimes flag a harmless 9-digit number that happens to look like a routing number.
- It only checks what visitors type. It doesn’t scan your Knowledge Base, uploaded files or images, or anything sent before it was installed.
- Masking changes what goes to the AI, not what stays on your site: the visitor’s chat history and the Discussions log still hold what they typed. Privacy First and the Discussions settings are covered on the data privacy page.
- If Moderation is on for a chatbot, the raw message goes to OpenAI’s moderation service before this snippet runs. If the data must stay with your own provider (Azure, for example), keep Moderation off.