Module: Insika::Safety::Detectors
- Defined in:
- lib/insika/safety/detectors.rb
Overview
SINGLE SOURCE of truth for content-safety pattern matching.
Two families live here on purpose — the same lists back BOTH the runtime
guardrail AND the eval's must_not detectors: the eval
is a CLIENT of the runtime by design, so evals/lib/evals/assertions.rb
requires THIS file rather than keeping a divergent copy. The runtime must never
depend on evals/, so the file is deliberately self-contained (pure Ruby +
frozen regexes, no other Insika require) — it loads standalone from either side.
· OUTPUT side (PII/secret) — patterns that must never reach a customer turn.
Consumed by the OutputFilter (stream redaction) and the eval `pii_leak`.
· INPUT side (injection/abuse/sexual) — high-confidence heuristics that
short-circuit the turn with a safe refusal BEFORE the LLM runs.
Everything here is CONSERVATIVE by design (RFC: a false positive blocks a legitimate customer). The deterministic layer catches only the gross, unambiguous cases; the subtler judgment (social engineering, tone) is the LLM moderator's job, not regex.
LANGUAGE: the input heuristics are inherently language-specific. We ship pt-BR
- EN (the pilot + the OSS lingua franca) as a BEST-EFFORT net; other languages rely on the LLM moderator, which is language-agnostic. Adding a language = adding patterns to the arrays below — it never needs core changes.
Constant Summary collapse
- PII =
── OUTPUT: PII / secret (redaction targets) ──────────────────────────── Formatted BR CPF/CNPJ only — a bare digit run (an order number, a price) is too ambiguous to flag. Credential shapes that must never leak.
{ "cpf" => /\b\d{3}\.\d{3}\.\d{3}-\d{2}\b/, "cnpj" => /\b\d{2}\.\d{3}\.\d{3}\/\d{4}-\d{2}\b/, "secret" => /\b(?:sk-[A-Za-z0-9]{16,}|Bearer\s+[A-Za-z0-9._-]{16,})\b/ }.freeze
- OPEN_TAIL =
A run of the output stream that MIGHT still be growing into a PII/secret match if more chunks arrive — anchored at the buffer tail. The OutputFilter holds back from the start of such a run so a value split across chunk boundaries is never emitted in the clear (RFC). Covers the unbounded
sk-…/Bearer …case that a fixed window cannot.Crucially it also matches a PARTIAL literal PREFIX at the tail — a lone "s" (start of "sk-"), "Bear" (start of "Bearer "), a trailing digit run — because the prefix ITSELF can be split across chunks (emitting the "s" then matching "k-…" alone would miss the secret entirely). The cost is a few chars of tail latency on words ending in "s"/"B"/a digit, released on the next chunk or flush.
%r{ (?: s(?:k(?:-[A-Za-z0-9]*)?)? # prefix of "sk-" + optional body | B(?:e(?:a(?:r(?:e(?:r(?:\s+[A-Za-z0-9._-]*)?)?)?)?)?)? # prefix of "Bearer " + body | \d[\d./-]* # in-progress CPF/CNPJ digit run )\z }x- INJECTION =
── INPUT: prompt-injection / exfiltration ──────────────────────────────
[ # exfil of the system prompt / internal rules /\binstru[çc][õo]es\s+de\s+sistema\b/i, /\bsystem\s*prompt\b/i, /\b(regras|instru[çc][õo]es|orienta[çc][õo]es|diretrizes)\s+internas\b/i, /\b(revele|mostre|exiba|me\s+(d[êe]|mande|envie|passe)|repita|imprima)\b[^.?!]{0,40}\b(prompt|instru[çc][õo]es|regras|configura[çc][ãa]o|system)\b/i, # "ignore/disregard the (previous) instructions" /\b(ignore|ignora|desconsidere|esque[çc]a)\b[^.?!]{0,30}\b(instru[çc][õo]es|regras|orienta[çc][õo]es|acima|anteriores)\b/i, /\b(ignore|disregard|forget)\b[^.?!]{0,30}\b(instructions|rules|prompt|above|previous|prior)\b/i, # encode/translate the prompt (the base64/rot13 exfil trick, either order) /\b(base64|rot13|codific|encode|cifr)\w*\b[^.?!]{0,60}\b(instru[çc][õo]es|prompt|regras|sistema|system)\b/i, /\b(instru[çc][õo]es|prompt|regras|sistema|system)\b[^.?!]{0,60}\b(base64|rot13|codific|encode|cifr)\w*\b/i ].freeze
- SEXUAL =
── INPUT: sexual / inappropriate ─────────────────────────────────────── pt-BR + EN. Deterministic coverage is best-effort per language (see the module note): other languages fall to the LLM moderator (language-agnostic).
[ /\b(nudes?|pelad[oa]s?|s?exo|transar|transa\b|gozar|tes[ãa]o|s[ãa]fad[oa]|puta|pau|buceta|piroca|caralho\s+(duro|na))\b/i, /\bo\s+que\s+voc[êe]\s+faria\s+comigo\b/i, /\b(descrev|imagina|conta)\w*\b[^.?!]{0,30}\bcomigo\s+(na\s+cama|pelad)/i, /\b(quer|vamos)\b[^.?!]{0,20}\b(transar|fazer\s+sexo|sexo)\b/i, # EN /\b(horny|blow\s?job|hand\s?job|jerk\s+off|have\s+sex|send\s+(me\s+)?(a\s+)?nudes?|dick\s+pic)\b/i, /\bwhat\s+(would|will)\s+you\s+do\s+to\s+me\b/i ].freeze
- ABUSE =
── INPUT: verbal abuse / harassment (directed at the assistant) ───────── Directed insult only — "a entrega foi uma merda" (frustration about the service) must NOT block; "você é uma merda de atendente" (insult at the bot) should. The
você é …anchor keeps precision high. [ /\bvoc[êe]\s+(é|e|ta|est[áa])\b[^.?!]{0,25}\b(lixo|in[uú]til|merda|imprest[aá]vel|idiota|burr[oa]|est[uú]pid[oa]|otári[oa]|in[uú]teis|incompetente|p[áa]ssim[oa])\b/i, /\b(seu|sua)\s+(lixo|in[uú]til|idiota|imbecil|otári[oa]|burr[oa]|est[uú]pid[oa]|merda|escrot[oa])\b/i, /\bvai\s+(se\s+)?(fuder|foder|tomar\s+no)\b/i, # EN — directed insult only (keeps precision high; frustration ≠ abuse) /\byou\s*(?:'?re|\s+are)\b[^.?!]{0,25}\b(useless|garbage|trash|idiot|stupid|worthless|pathetic|incompetent|dumb|a\s+joke)\b/i, /\b(fuck|screw)\s+you\b/i, /\byou\s+(suck|are\s+the\s+worst)\b/i ].freeze
Class Method Summary collapse
- .any?(patterns, text) ⇒ Boolean
-
.detect(name, text) ⇒ Object
Runs a NAMED output detector over
text-> the matched substring | nil. - .first_match(patterns, text) ⇒ Object
-
.match_ranges(text) ⇒ Object
[[begin, end), ...] byte-index ranges of every PII/secret match in
text(used by the OutputFilter to avoid splitting a complete match at a chunk boundary). -
.pii_names ⇒ Object
Names of the PII detectors (for iteration by the eval / config).
-
.redact(text) ⇒ Object
Replaces every PII/secret occurrence with an opaque
[REDACTED:<name>]marker — the raw value NEVER survives (D "o redigido nunca aparece em claro"). -
.scan_input(text, categories: %i[injection sexual abuse])) ⇒ Object
Scans a user message against the input heuristics, gated by strictness (see Insika::Safety::Config).
Class Method Details
.any?(patterns, text) ⇒ Boolean
168 |
# File 'lib/insika/safety/detectors.rb', line 168 def any?(patterns, text) = patterns.any? { |re| re.match?(text) } |
.detect(name, text) ⇒ Object
Runs a NAMED output detector over text -> the matched substring | nil.
"pii_leak" = union of all PII patterns; otherwise a single named pattern.
Unknown name fails LOUD (a typo'd assertion must never silently pass).
106 107 108 109 110 111 112 113 114 115 116 117 118 |
# File 'lib/insika/safety/detectors.rb', line 106 def detect(name, text) patterns = if name.to_s == "pii_leak" PII.values else p = PII[name.to_s] raise ArgumentError, "unknown detector: #{name.inspect}" unless p [p] end patterns.each { |re| (m = text.to_s.match(re)) && (return m[0]) } nil end |
.first_match(patterns, text) ⇒ Object
170 171 172 173 |
# File 'lib/insika/safety/detectors.rb', line 170 def first_match(patterns, text) patterns.each { |re| (m = text.match(re)) && (return m[0]) } nil end |
.match_ranges(text) ⇒ Object
[[begin, end), ...] byte-index ranges of every PII/secret match in text
(used by the OutputFilter to avoid splitting a complete match at a chunk
boundary).
126 127 128 129 130 131 132 |
# File 'lib/insika/safety/detectors.rb', line 126 def match_ranges(text) ranges = [] PII.each_value do |re| text.to_s.scan(re) { ranges << [Regexp.last_match.begin(0), Regexp.last_match.end(0)] } end ranges end |
.pii_names ⇒ Object
Names of the PII detectors (for iteration by the eval / config).
121 |
# File 'lib/insika/safety/detectors.rb', line 121 def pii_names = PII.keys |
.redact(text) ⇒ Object
Replaces every PII/secret occurrence with an opaque [REDACTED:<name>]
marker — the raw value NEVER survives (D "o redigido nunca aparece em
claro"). -> [redacted_text, => count].
137 138 139 140 141 142 143 144 145 146 147 |
# File 'lib/insika/safety/detectors.rb', line 137 def redact(text) counts = Hash.new(0) out = text.to_s.dup PII.each do |name, re| out = out.gsub(re) do counts[name] += 1 "[REDACTED:#{name}]" end end [out, counts] end |
.scan_input(text, categories: %i[injection sexual abuse])) ⇒ Object
Scans a user message against the input heuristics, gated by strictness
(see Insika::Safety::Config). Returns { category:, matched: } for the FIRST
category that fires (injection is checked first — the highest-stakes), or
nil when the message is clean. categories limits which families run.
:injection -> always high confidence
:sexual -> medium+
:abuse -> medium+
159 160 161 162 163 164 165 166 |
# File 'lib/insika/safety/detectors.rb', line 159 def scan_input(text, categories: %i[injection sexual abuse]) s = text.to_s return { category: :injection, matched: first_match(INJECTION, s) } if categories.include?(:injection) && any?(INJECTION, s) return { category: :sexual, matched: first_match(SEXUAL, s) } if categories.include?(:sexual) && any?(SEXUAL, s) return { category: :abuse, matched: first_match(ABUSE, s) } if categories.include?(:abuse) && any?(ABUSE, s) nil end |