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

Class Method Details

.any?(patterns, text) ⇒ Boolean

Returns:

  • (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_namesObject

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