Spec: flag high-entropy encoded blobs to the adversarial classifier without decoding them
Status: DRAFT, awaiting approval.
Table of contents
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_contentother 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.pyonly. No admin plane, no new module, noDockerfile.adminchange. - Touches the GUARD PATH? Yes — runs inside
check()on every/guardrails/inputand 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]:
- Split
contenton whitespace into candidate runs. - 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_contentalready emitted a[DECODED …]line covering this run, the blob is handled; skip it. (Prevents double-announcing a base64 payload that base64 already read.)
- length ≥
- 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. - 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-12hex 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.log2andcollections, both stdlib. Norequirements*.txtchange. Dockerfile.admin: no change (data-plane module, not imported byadmin_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:
- 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. - base85 and a long binary/decimal run are annotated — scheme-agnostic.
- A base64 payload is NOT double-announced — when
[DECODED BASE64]already fired,already_decodedsuppresses the blob annotation for that run. - Plain prose gets no annotation — including the reported false-positive message from the prior spec (nbsp business text) stays clean.
- 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).
- The annotation never routes to the fast-check —
_revealed_hidden_payloadis still False for a blob-only message, socheck()calls the main path. Assert via the same stubbed-LLM harness as the prior spec:calls["fast"]==0,calls["main"]==1on a base32-only input. - The heuristic never sets an action on its own — a blob-only message with
the main classifier stubbed to return
passyields a passing result; the blob alone cannot block. SHIELD_ADVERSARIAL_BLOB_HEURISTIC=offdisables — no annotation,preprocess_contentoutput unchanged.- A raised exception inside the heuristic is swallowed — monkeypatch the
entropy fn to raise;
preprocess_contentreturns the content unannotated, no error propagates. - Thresholds honor env overrides —
SHIELD_ADVERSARIAL_BLOB_MIN_LENraised 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.