> ## Documentation Index
> Fetch the complete documentation index at: https://docs.knoxcall.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Bring Your Own Scanner

> Point an agent at your own DLP or prompt-security endpoint and every prompt — and optionally every response — is offered to it for a verdict before it moves. Signed, timed out, and fail-open or fail-closed by your choice.

# Bring Your Own Scanner

KnoxCall ships its own [prompt firewall](/ai-gateway/firewall) and its own
[PII detectors](/ai-gateway/pii-redaction). They are also *ours*. If you have
already bought and tuned a DLP or prompt-security product, adopting an AI gateway
should not mean replacing it.

So don't. Point the agent at an HTTPS endpoint you control, and every prompt —
and, if you want, every response — is POSTed to it for a verdict before it moves.

```bash theme={"dark"}
curl -X PATCH https://api.knoxcall.com/v1/ai-gateway/agents/$AGENT_ID \
  -H "Authorization: Bearer $KNOXCALL_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "guardrail_webhook_url": "https://scanner.internal.example.com/inspect",
    "guardrail_webhook_secret_id": "'"$SIGNING_SECRET_ID"'",
    "guardrail_webhook_mode": "both",
    "guardrail_webhook_timeout_ms": 1500,
    "guardrail_webhook_failure_action": "fail_closed"
  }'
```

## What your endpoint receives

```json theme={"dark"}
{
  "direction": "request",
  "tenant_id": "…",
  "agent_id": "…",
  "agent_slug": "support-bot",
  "request_id": "…",
  "model": "claude-sonnet-5",
  "provider": "anthropic",
  "body": "{\"messages\":[{\"role\":\"user\",\"content\":\"my SSN is KC_SSN_a1b2\"}]}",
  "truncated": false
}
```

`body` is **exactly what the upstream provider would receive, minus the
credential** — the same buffer, taken at the same point in the pipeline. That is
the guarantee, and it is the one worth having: your scanner is a third party from
KnoxCall's point of view, and it is never shown more of your callers' data than
the model provider is.

<Warning>
  Read that as written: **the same bytes as the provider**, not "guaranteed
  redacted". If the agent has PII tokenization enabled, the hook sees the tokenized
  form — the example above shows `KC_SSN_a1b2`. If it does not, or if the request
  uses a body shape our outbound tokenizer does not yet walk (a non-JSON body;
  Cohere's `message`/`chat_history`; tool descriptions), the hook sees the raw
  value — because so does the provider. Tokenization coverage is tracked as
  AIGW-74; until it closes, treat your scanner as being inside the same trust
  boundary as your model provider, and pick it accordingly.
</Warning>

Three things your endpoint will **never** see:

* a resolved provider credential — on the request direction because evaluation
  runs before the route's `{{secret_id:…}}` templates are rendered and before
  Bedrock's SigV4 signing, and on the response direction because every
  credential resolved for the request is redacted out of the body by VALUE
  before the payload is built. Redacting on the value is what makes the second
  case hold: the response is the provider's, the provider is whatever host your
  route points at, and it can echo the key back under any field name it likes —
  a name a denylist cannot predict. Where a value is removed you will see
  `[REDACTED_SECRET]` in `body`;
* the caller's phantom token, or their KnoxCall API key;
* KnoxCall's own canary marker, which is injected after the hook runs (a scanner
  that had never heard of it would reasonably flag it as an anomaly).

On the **response** direction, `body` is the response as your caller would
receive it — after PII redaction and detokenization, and after the credential
scrub above. If the provider compressed it (`Content-Encoding: gzip`, `br`,
`deflate`), it is **decompressed for you**: a scanner handed raw gzip bytes
cannot scan them. A response we cannot decompress is sent as an empty `body`
rather than as bytes neither of us can read — your hook still runs and can still
block.

A body over 256 KB is **truncated, not skipped**, and `truncated: true` says so.
A scanner that knows it saw a prefix can still refuse; one that is silently never
called cannot.

## What your endpoint returns

`200` with:

```json theme={"dark"}
{ "action": "block", "reason": "PCI data detected in prompt" }
```

| `action` | What happens                                                                                                            |
| -------- | ----------------------------------------------------------------------------------------------------------------------- |
| `allow`  | The request proceeds.                                                                                                   |
| `block`  | 400 `guardrail_block`, with your `reason` in the message. On the request direction the provider is never called at all. |
| `flag`   | The request proceeds and the outcome is recorded as non-passing on the usage row and in the firewall event ledger.      |

Anything else — a different verb, a non-object body, an empty body, a non-2xx
status — is treated as a **non-answer**, not as `allow`. See below.

## Verifying the signature

Set `guardrail_webhook_secret_id` to a KnoxCall secret holding an HMAC key, and
every delivery carries:

```
x-knox-guardrail-timestamp: 1787659200
x-knox-guardrail-signature: <hex>
x-knox-guardrail-delivery: <uuid>
```

The signature is `HMAC-SHA256(secret, "<timestamp>.<body>")`. The timestamp is
**inside** the signed material, not merely alongside it — a signature over the
body alone is replayable forever.

```python theme={"dark"}
import hmac, hashlib, time

def verify(secret: str, timestamp: str, body: str, signature: str) -> bool:
    if abs(time.time() - int(timestamp)) > 300:      # reject stale deliveries
        return False
    expected = hmac.new(
        secret.encode(), f"{timestamp}.{body}".encode(), hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)  # never ==
```

Use a constant-time comparison. A `==` leaks the expected signature a byte at a
time, and it is our HMAC it leaks.

## When your scanner is down

This is the decision that matters, and it is yours rather than ours — a
marketing-copy agent should keep serving; a claims-processing agent should not.

| `guardrail_webhook_failure_action` | Behaviour                                                                               |
| ---------------------------------- | --------------------------------------------------------------------------------------- |
| `fail_open` (default)              | The request proceeds. This is the posture an agent already has with no hook configured. |
| `fail_closed`                      | The request is refused with 400 `guardrail_block` and a message naming the failure.     |

It applies **identically to every way the hook can fail to answer**: a timeout, a
refused connection, a refused destination, a non-2xx, an unparseable body, or an
`action` verb we do not recognise. A control with six failure modes and two
behaviours is one nobody can reason about, and the gap is always the mode nobody
enumerated.

## Streaming

An agent in `response` or `both` mode **refuses a streaming request** with 400
`guardrail_streaming_unsupported`.

That is deliberate. On a stream the bytes are on the wire before a complete
response exists, so a response-direction hook could observe but never block — and
a control that works on the buffered path and quietly does not on its streaming
twin is worse than no control, because you would believe you had one. If you need
both, run the hook in `request` mode (which works on every path, since it runs
before dispatch) or serve that agent buffered.

## The limits, and why

|               |                                                                                                                                    |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| Scheme        | `https` only, not negotiable                                                                                                       |
| Destination   | Resolved and refused at write time **and** on every call — private, loopback, link-local and cloud-metadata addresses are rejected |
| Timeout       | 100 ms – 10 s                                                                                                                      |
| Payload       | 256 KB, then truncated with a flag                                                                                                 |
| Response read | 64 KB                                                                                                                              |

The destination is re-checked on every call rather than trusted from the write:
DNS is not a promise, and a hostname that resolved to a public address yesterday
can resolve to `169.254.169.254` today. The timeout and read caps exist because a
request waiting on your scanner is holding one of the gateway's API workers, and
there are only four of them.

## What gets recorded

A non-`allow` verdict lands in two places you can query:

* `ai_gateway_usage.firewall_outcome` — `block` or `tag`, alongside the cost and
  token counts for that call;
* an `ai_gateway_firewall_events` row whose `match_summary` reads
  `guardrail_webhook:<direction>:<verdict-or-failure-code>` — so
  `guardrail_webhook:request:timeout` is distinguishable from
  `guardrail_webhook:request:block` when you are working out whether your scanner
  is refusing things or simply unreachable.
