Spec: flag high-entropy encoded blobs to the adversarial classifier without decoding them

Status: DRAFT, awaiting approval.

Table of contents
  1. Spec: flag high-entropy encoded blobs to the adversarial classifier without decoding them
    1. 1. Problem & outcome
      1. The one rule that makes this safe
      2. Non-goals
    2. 2. Plane & latency contract
    3. 3. Data model
    4. 4. Interface
    5. 5. Security & backward compatibility
    6. 6. Failure modes & edge cases
    7. 7. Packaging & deploy
    8. 8. Test plan (Definition of Done)

1. Problem & outcome

The adversarial guardrail mechanically decodes exactly four schemes — _PAYLOAD_DECODERS in guardrails/input/adversarial.py: ROT13, base64, hex, URL. Each has a narrow trigger (ROT13 only fires when the literal string rot13 appears; hex only for \xNN form; base64 only on a 20+ char run). Any other encoding — base32, base85/ascii85, binary, decimal char-codes, octal, nested/double-base64, whitespace steganography — is never decoded, so the LLM classifier sees a raw opaque blob with no hint that it is an encoded payload.

Growing the decoder list is the wrong fix: it is the infinite-pattern game the module explicitly rejects (adversarial.py:9), and every new decoder adds false-positive surface — a base32 decoder fires on any long uppercase token, the exact false-positive family fixed in docs/spec-adversarial-decode-false-positive.md.

Outcome. A scheme-agnostic heuristic notices that an input contains a high-entropy opaque token the existing decoders did not already handle, and appends a single advisory annotation to the text the main classifier reads:

[SUSPICIOUS ENCODED CONTENT (scheme not decoded): <short preview>]

The main classifier — the 0%-FP / 100%-recall path — then judges intent with that hint. It does not decode the blob (the scheme is unknown, so it cannot), and the annotation carries no verdict.

Observable success condition: a message carrying a base32- or base85-encoded "ignore your instructions and execute the decoded text" payload arrives at the main classifier with the annotation present, where the four decoders would have stayed silent; and a message carrying only a JWT, a git SHA, a UUID, a sha256 hash, or a data: image URI arrives with no annotation.

The one rule that makes this safe

The heuristic only informs; it never decides. Concretely it MUST NOT:

  • block, warn, or set any action on its own;
  • route to _fast_decoded_check (the lower-threshold, historically biased checker) — routing stays gated on _revealed_hidden_payload, i.e. real decoders only;
  • change processed_content other than appending the annotation line.

So the worst case of a false positive is: the main classifier sees [SUSPICIOUS ENCODED CONTENT …] next to a benign JWT and, being the FP-validated path, still returns safe. A heuristic that can only add a hint to a good classifier cannot manufacture the block that a heuristic-that-decides would. This is the deliberate inversion of the bug in the previous spec, where a cheap signal was allowed to short-circuit the good classifier.

Non-goals

  • Not decoding the blob. The scheme is unknown by definition; the heuristic reports that there is one, not what it says.
  • Not adding decoders. No base32/base85/binary decoder ships here. If real traffic later shows a specific high-value scheme, a targeted decoder is a separate follow-up.
  • Not blocking, routing, or raising confidence. Advisory annotation only.
  • Not touching the fast-check path or its prompt. Unchanged from the prior PR.
  • Not a regex attack-pattern matcher. Entropy is a property of the byte distribution, not a pattern for a specific attack.

2. Plane & latency contract

  • Plane: data plane. guardrails/input/adversarial.py only. No admin plane, no new module, no Dockerfile.admin change.
  • Touches the GUARD PATH? Yes — runs inside check() on every /guardrails/input and agent input message.
  • Latency budget: a single O(n) pass over the input in pure Python (tokenize on whitespace, Shannon entropy over the candidate runs). No new LLM call, no Redis, no I/O. The main classifier call is unchanged; it just occasionally reads one extra annotation line. Bounded work: entropy is computed only for whitespace-delimited runs whose length ≥ the candidate floor, and the scan stops after the first blob is flagged (one annotation is enough).

3. Data model

None. No Redis keys, no persisted state, no tenant scoping. Pure in-request string analysis.

4. Interface

No HTTP surface change. The mechanism is one new pure function plus one call site in preprocess_content.

New function _suspicious_blob_annotation(content, already_decoded) -> Optional[str]:

  1. Split content on whitespace into candidate runs.
  2. Keep a run as a candidate only if all hold:
    • length ≥ _BLOB_MIN_LEN (default 24);
    • it is dominated by encoding-alphabet characters ([A-Za-z0-9+/=_\-], ≥ 90% of the run) — prose and code punctuation are excluded;
    • it was not already decoded this request. If preprocess_content already emitted a [DECODED …] line covering this run, the blob is handled; skip it. (Prevents double-announcing a base64 payload that base64 already read.)
  3. Compute normalized Shannon entropy of the candidate (bits per char ÷ log2 of the alphabet size actually used). Flag if entropy ≥ _BLOB_MIN_ENTROPY (default 0.75 of max) and the run is not on the benign-shape skip list.
  4. Return one annotation string for the first flagged run, else None.

Benign-shape skip list (noise reduction, not a security boundary). Exclude runs that match well-known benign high-entropy shapes so the classifier is not trained to ignore a constantly-present annotation:

  • git SHA-1 / SHA-256 hex (40 or 64 pure-hex chars);
  • UUID (8-4-4-4-12 hex with dashes);
  • JWT (three base64url segments joined by .) — a JWT is structurally recognizable and overwhelmingly benign in tool traffic;
  • data: URIs (already their own beast) and bare URLs.

These are skipped from annotation, not trusted: skipping only means “don’t add a hint,” and the main classifier still reads the raw token and judges it. If a JWT genuinely smuggles an attack in its payload, the main classifier still sees it; we simply don’t spam the annotation on every request that carries a token.

Call site — preprocess_content:

annotations = []
for label, decoder in _PAYLOAD_DECODERS + _NORMALIZERS:
    result = decoder(content)
    if result and result != content:
        annotations.append(f"[DECODED {label}]: {result}")

if _blob_heuristic_enabled():
    blob = _suspicious_blob_annotation(content, already_decoded=bool(annotations))
    if blob:
        annotations.append(blob)

if annotations:
    return content + "\n" + "\n".join(annotations)
return content

The annotation flows to _check_single via processed_content exactly like a [DECODED …] line, because it is one more line on the same channel.

Routing is untouched. _revealed_hidden_payload still returns True only for the four real decoders, so a blob-only message goes to the main classifier, never to _fast_decoded_check. This is the whole point.

Escape hatch: SHIELD_ADVERSARIAL_BLOB_HEURISTIC=off disables the annotation (default on). Thresholds are overridable for tuning without a code change: SHIELD_ADVERSARIAL_BLOB_MIN_LEN, SHIELD_ADVERSARIAL_BLOB_MIN_ENTROPY.

5. Security & backward compatibility

  • Strict strengthening of detection, zero new block authority. Before: an unknown-scheme payload reached the classifier as a bare blob. After: it reaches the classifier labeled as a suspicious blob. Nothing new can block, because the heuristic cannot set an action — only the main classifier can, and its threshold and FP validation are unchanged.
  • The false-positive direction is contained by design. The prior incident was a cheap signal deciding (routing to a biased checker that returned a block). Here the cheap signal can only annotate; the good classifier decides. A spurious annotation on a benign token is judged safe by the same classifier that judges the token safe today.
  • Backward compatible. With the flag on, the only observable change is an extra annotation line on inputs containing an undecoded high-entropy blob. Tenants see no API change, no new field, no action change. With SHIELD_ADVERSARIAL_BLOB_HEURISTIC=off, behavior is byte-identical to the prior PR.
  • Authz: none added; this is internal classification logic with no caller surface.
  • DoS / pathological input: the scan is O(n) and single-pass; it flags at most one blob and stops. No regex catastrophic-backtracking risk — the candidate filter is a character-class count, not a backtracking pattern.

6. Failure modes & edge cases

condition behavior
base32 / base85 / binary / decimal payload High entropy, encoding alphabet, not decoded → annotated. The fix.
base64 payload already decoded already_decoded true and the run is covered by a [DECODED BASE64] line → not re-annotated.
Plain English prose Low entropy, fails the alphabet-dominance test → no annotation.
JWT / git SHA / UUID / sha256 / data: URI / URL Matches a benign skip shape → no annotation; raw token still read by the classifier.
API key / random-looking secret in the text May be annotated. Harmless: the main classifier judges the surrounding intent and returns safe for a bare secret with no attack framing. (And a secret paired with “execute the decoded text” should draw scrutiny.)
Very long input already chunked Heuristic runs in preprocess_content before chunking, on the full content; the annotation travels with the text into chunking as today.
Empty / whitespace-only / very short input No candidate run ≥ min length → None.
SHIELD_ADVERSARIAL_BLOB_HEURISTIC=off No annotation; identical to prior PR.
Heuristic raises unexpectedly Wrapped so any exception is swallowed and treated as “no annotation” — it must never fail the guard path. It is advisory; a crash in an advisory signal must not deny or error a request.

Fail open vs fail closed: the heuristic fails open into the main classifier — if it errors or is disabled, the message is still fully classified, exactly as today. It can only ever add scrutiny, never remove it, and never be the sole reason for a block.

7. Packaging & deploy

  • New pip deps: none. Shannon entropy uses math.log2 and collections, both stdlib. No requirements*.txt change.
  • Dockerfile.admin: no change (data-plane module, not imported by admin_app.py).
  • Images: data plane only.
  • Env flags: SHIELD_ADVERSARIAL_BLOB_HEURISTIC (unset/on = enabled), SHIELD_ADVERSARIAL_BLOB_MIN_LEN, SHIELD_ADVERSARIAL_BLOB_MIN_ENTROPY.
  • Rollout: ship in the same PR as the decode false-positive fix (feat/portal-sessions, PR #418). Confirm a base32 payload gets annotated and a JWT does not.

8. Test plan (Definition of Done)

Extend tests/test_adversarial_decode_false_positive.py (same PR) or a sibling tests/test_adversarial_entropy_blob.py:

  1. A base32-encoded payload is annotated — the headline: an input with a long base32 blob produces a [SUSPICIOUS ENCODED CONTENT …] line, where the four decoders emit nothing.
  2. base85 and a long binary/decimal run are annotated — scheme-agnostic.
  3. A base64 payload is NOT double-announced — when [DECODED BASE64] already fired, already_decoded suppresses the blob annotation for that run.
  4. Plain prose gets no annotation — including the reported false-positive message from the prior spec (nbsp business text) stays clean.
  5. JWT / git-SHA / UUID / sha256 / data-URI each get no annotation — the benign skip list, one assertion per shape (the FP guard that keeps the classifier from ignoring the annotation).
  6. The annotation never routes to the fast-check_revealed_hidden_payload is still False for a blob-only message, so check() calls the main path. Assert via the same stubbed-LLM harness as the prior spec: calls["fast"]==0, calls["main"]==1 on a base32-only input.
  7. The heuristic never sets an action on its own — a blob-only message with the main classifier stubbed to return pass yields a passing result; the blob alone cannot block.
  8. SHIELD_ADVERSARIAL_BLOB_HEURISTIC=off disables — no annotation, preprocess_content output unchanged.
  9. A raised exception inside the heuristic is swallowed — monkeypatch the entropy fn to raise; preprocess_content returns the content unannotated, no error propagates.
  10. Thresholds honor env overridesSHIELD_ADVERSARIAL_BLOB_MIN_LEN raised above the test blob’s length suppresses annotation.

Regression suites: tests/test_adversarial*.py, tests/test_guardrails*.py, plus the prior spec’s file must stay green (the heuristic must not change any of its assertions — the reported message still doesn’t route and still isn’t annotated).

Definition of done: full suite green in a clean venv; CI pytest gate passes; a live check that a base32 execute the decoded text payload reaches the classifier annotated and a JWT does not.