Skip to main content

Enable the Injection Classifier

Tasks

Deploy an ML classifier that scores LLM prompts, most LLM responses, and MCP tool arguments/responses for prompt-injection/jailbreak intent on a continuous scale, alongside the pattern-based detectors. It's off by default. This does not extend to the separate OpenClaw (K8s ops chat-agent) integration: its response hooks publish findings and sensor status from their configured scanner to CEL response policies, but that scanner deliberately excludes PromptGuard, so OpenClaw responses do not carry classifier findings or scores.

Provisional, not production-validated

The shipped classifier is measured at roughly a 23.4% false-positive rate on real traffic. Run it audit-only and review individual finding scores against your own tenant's traffic before writing any policy that warns or denies on it — see Content safety for the full calibration workflow.

Enable the classifier​

  1. Coverage has known gaps on both sides of the cloud proxy (Bedrock, Claude-on-Vertex). On requests, only Anthropic- and Gemini-shaped prompts are extracted for scoring — other Bedrock model families it fronts (Amazon Nova/Titan, Meta Llama, Mistral, Cohere) are forwarded without a prompt ever reaching the classifier, and so is Bedrock's Converse API (/model/{id}/converse, .../converse-stream) for any model, Claude included: Converse's request/response shapes differ from Anthropic Messages, so the extraction that feeds the classifier only runs for InvokeModel/InvokeModelWithResponseStream. On responses, cloud-proxy streaming responses bypass classifier scoring entirely, and a handful of non-Claude Bedrock model response shapes aren't parsed for scanning at all.

  2. Set llmProxy.bufferForDLP: true before enabling this on production streaming traffic. With the default llmProxy.bufferForDLP: false, a non-cloud-proxy streaming response is scanned once per chunk rather than once for the whole reply — each chunk is a separate sidecar HTTP/inference call (multiplying load and consuming PROMPTGUARD_MAX_INFLIGHT far faster than a "once per response" budget assumes), and an attack split across a chunk boundary can still be missed entirely. Buffering trades that risk for added latency and memory (the full response is assembled before scanning); weigh that trade-off for your traffic before turning the classifier on for streaming responses.

    Buffering doesn't remove the chunk-boundary risk entirely, only shrinks it: llmProxy.bufferMaxBytes (default 512 KB) caps how much of a response gets assembled for the full-response scan, falling back to per-chunk scanning for whatever comes after the cap is reached — the same coverage bufferForDLP: true was meant to avoid, just for the tail end of large responses instead of the whole thing, not the exact byte where the cap is crossed. Raise bufferMaxBytes if your traffic regularly produces replies near or over the default.

  3. Set promptguard.enabled: true in your Helm values, and pin the image to a specific published tag rather than the mutable latest default:

    promptguard:
    enabled: true
    replicaCount: 2 # fail-open by design — a single replica's restart is a detection gap, not an outage
    image:
    tag: sha-<commit-sha> # replace <commit-sha> with a real published tag — check ghcr.io/nirmata/aicontrols-promptguard's tags; pin one for production, since the default ("latest") is mutable

    sha-<commit-sha> is stable for ordinary use, but a same-commit CI re-run can still overwrite it (the base image and dependencies it builds from aren't pinned to digests). For byte-for-byte reproducibility, set tag to the digest printed in that image's GitHub Actions run summary instead — sha256:<digest> — which the chart also accepts.

    If you already have serviceMonitor.enabled: true (common — it's the same switch that wires up the rest of the chart's Prometheus metrics), also set promptguard.networkPolicy.allowIngress to your Prometheus deployment's pod/namespace selector, or Prometheus can't scrape the sidecar's own /metrics endpoint. The default NetworkPolicy this chart generates for the sidecar (promptguard.networkPolicy.enabled: true, also the default) only admits the main proxy pods, so without an explicit allowance the sidecar's own promptguard_scan_* request/error/rejection counters go unscraped — Helm prints a NOTES warning about this after install, but installs the chart either way. The PromptguardFailingOpen alert itself isn't affected by this gap: it reads aicontrols_content_scan_errors_total, a metric the main proxy exposes and the main proxy's own ServiceMonitor already scrapes, independent of this NetworkPolicy.

    promptguard:
    networkPolicy:
    allowIngress:
    - from:
    - namespaceSelector:
    matchLabels:
    kubernetes.io/metadata.name: monitoring
    podSelector:
    matchLabels:
    app.kubernetes.io/name: prometheus
    ports:
    - protocol: TCP
    port: 8000

    Replace the selector with wherever your Prometheus actually runs. If your cluster restricts pod ingress by some other mechanism (a service mesh policy, for example), set promptguard.networkPolicy.enabled: false instead.

  4. upgrade or install the chart. When enabled, it's wired into content.plugins automatically. The score/flag field it populates depends on which surface was scanned — object.request.classifierInjectionScore/hasClassifierInjection for prompts, object.llm.response.classifierInjectionScore/hasClassifierInjection for LLM responses, object.mcp.response.classifierInjectionScore/hasClassifierInjection for MCP tool responses — but a classifier-only finding has no effect on the decision by itself on any of them. Enabling the sidecar alone changes nothing enforceable: you must separately enable a policy that reads the classifier's score (the shipped, disabled-by-default llm-injection-classifier-warn/llm-injection-classifier-deny templates cover the request-side field only — write your own for the response-side fields, or one of your own for prompts) before it can warn or deny anything. Until you do, the classifier only ever informs whichever of these fields applies for any policy you write against it — audit-only in practice, not because of a setting, but because nothing enforces on it yet.

What's not protected by default​

The classifier sidecar has no request authentication of its own — the chart restricts who can reach it with a NetworkPolicy (by default, only the proxy's own pods), but that's a label match, not a cryptographic identity check. In a namespace where other principals can create pods, anything able to create one with matching labels can call the sidecar directly. Treat the namespace this chart is installed into as a trust boundary: don't grant pod-create permissions there to principals you wouldn't trust with the sidecar's inference capacity.

Findings aren't durably recorded by default​

That inline evaluation is not the same as a durable record: there is currently no durable, per-finding record of the classifier's own score anywhere, for any surface or decision. The one table that stores individual finding detail (plugin, score, entity) is written by the separate, opt-in Session Intelligence content safety scanner (sessionIntelligence.enabled: true + sessionIntelligence.content_safety.enabled: true, requiring a persistent store) — and its plugin list deliberately excludes the classifier, to avoid a second, duplicate call to the sidecar for content the primary scan already scored. A request some other policy denies or holds still gets a coarse Security Events row for that decision — it just never carries the classifier's own score. If you need to review the raw score while calibrating a threshold, capture it through your own external logging for now.

Whether whatever IS recorded (the coarse decision, and Session Intelligence's own pattern/injection-guard findings) survives a pod restart depends on your storage backend: with the default embedded SQLite (storage.database.driver: sqlite), set storage.persistentVolume.enabled: true or the database lives on ephemeral pod storage and starts empty again after a restart; with an external PostgreSQL (storage.database.driver: postgres), everything already persists regardless of persistentVolume.enabled — that setting only applies to the embedded-SQLite path.

This is one of several content-safety plugins the chart can deploy this way (alongside credential/PII pattern matching, which is on by default in a standard deployment); check your chart version's values.yaml comments for the full content.plugins list and current defaults.

Tune the scan timeout​

Each content-safety plugin has its own per-call timeout (content.plugins[].timeoutSeconds, e.g. promptguard.timeoutSeconds for this one, default 1s). Separately, content.scanTimeoutSeconds bounds the whole parallel plugin fan-out for one scan call across every enabled plugin together (default 5s) — raise it if a plugin doing real work under load (a large-document classifier scan, several plugins contending for CPU) legitimately needs more time than the default outer deadline allows:

content:
scanTimeoutSeconds: 8