hushfox / safety.js
CyberMax-tools's picture
Deploy hushfox
0cc8837 verified
Raw History Blame Contribute Delete
3.38 kB
// Hushfox safety filter: keeps every deck family-safe. Used by tests/words.test.mjs on the hand-written library and
// by the room Worker on every AI-generated category before it is cached or shown. Whole-word match on a normalised
// copy (accents stripped, lower case), so "glass" or "Scunthorpe"-style words are not caught by accident.
// (c) 2026 CyberMax. All rights reserved.
const BLOCK = [
// profanity and insults
'damn', 'hell', 'crap', 'shit', 'fuck', 'fucking', 'bitch', 'bastard', 'ass', 'arse', 'asshole', 'dick', 'cock', 'piss',
'bollocks', 'bugger', 'wanker', 'twat', 'prick', 'slut', 'whore', 'idiot', 'moron', 'stupid', 'retard', 'loser', 'dumb',
// sexual
'sex', 'sexy', 'porn', 'nude', 'naked', 'boobs', 'boob', 'breast', 'penis', 'vagina', 'orgasm', 'condom', 'erotic',
'stripper', 'strip club', 'brothel', 'fetish', 'kinky', 'lingerie', 'hooker', 'escort', 'viagra', 'onlyfans',
// drugs, alcohol excess, gambling
'cocaine', 'heroin', 'meth', 'weed', 'marijuana', 'cannabis', 'joint', 'bong', 'crack', 'ecstasy', 'lsd', 'drunk',
'hangover', 'vodka shot', 'shots', 'casino', 'gambling', 'cigarette', 'vape', 'tobacco',
// violence, weapons and death
'gun', 'rifle', 'pistol', 'shotgun', 'bullet', 'bomb', 'grenade', 'kill', 'killer', 'murder', 'suicide', 'blood',
'gore', 'corpse', 'torture', 'terrorist', 'massacre', 'stab', 'shoot', 'shooting', 'war crime', 'execution',
// hate and extremism
'nazi', 'hitler', 'kkk', 'racist', 'slave', 'slavery', 'genocide', 'jihad',
];
const N = (s) => String(s).normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
const SET = new Set(BLOCK.map(N));
// Harmless everyday phrases that contain a blocked word.
const ALLOW = new Set(['staple gun', 'glue gun', 'water gun', 'pop gun', 'water pistol', 'shooting star', 'bullet train'].map(N));
/** The blocked term found in text, or null. Checks single words and two-word phrases. */
export function blocked(text) {
if (ALLOW.has(N(text))) return null;
const w = N(text).split(' ').filter(Boolean);
for (let i = 0; i < w.length; i++) {
if (SET.has(w[i])) return w[i];
if (i + 1 < w.length && SET.has(`${w[i]} ${w[i + 1]}`)) return `${w[i]} ${w[i + 1]}`;
}
return null;
}
/** A word (or, with max 40, a category) is acceptable: letters (any language), spaces and simple punctuation, 1-max chars, not blocked. */
export function okEntry(s, max = 32) {
const t = String(s || '').trim();
return t.length >= 1 && t.length <= max && /^[\p{L}\p{N}][\p{L}\p{N} '’\-&.]*$/u.test(t) && !blocked(t);
}
/** Clean an AI answer {category, words[]} -> {cat, words[]} or null. Needs 8+ safe, distinct words. */
export function cleanAi(obj) {
if (!obj || typeof obj !== 'object') return null;
const cat = String(obj.category || '').trim().replace(/\s+/g, ' ');
if (!okEntry(cat, 40)) return null;
const seen = new Set();
const words = [];
for (const raw of Array.isArray(obj.words) ? obj.words : []) {
const w = String(raw || '').trim().replace(/\s+/g, ' ').replace(/^[-*\d.\s]+/, '');
const k = N(w);
if (!okEntry(w) || seen.has(k) || k === N(cat)) continue;
seen.add(k);
words.push(w.charAt(0).toUpperCase() + w.slice(1));
if (words.length >= 16) break;
}
return words.length >= 8 ? { cat: cat.charAt(0).toUpperCase() + cat.slice(1), words } : null;
}