# Getting started

How to send traffic through the firewall — direct API, transparent proxy, and agent CLI hooks — plus how to detect a compromised agent.

> **Sign in to mint a real key.** Every snippet below uses placeholders (`sfw_live_xxxxxxxx` for your key, `https://your-firewall.example.com` for the firewall URL). [Sign in](/sign-in) and open **Keys** in the dashboard to mint a key — the same snippets there are pre-filled with your real key prefix and firewall URL, ready to copy.

## Set your key as an environment variable

Mint a key under **Keys** — it's shown only once. Export it and every snippet below picks it up.

```bash
export SFW_KEY=sfw_live_xxxxxxxx...your-full-key
```

## Which integration should I use?

Four ways to put the firewall in front of your model. Pick the row that matches how you call the LLM — they can be combined.

| If… | Use |
| --- | --- |
| You call the OpenAI or Anthropic SDK and want zero code changes | [Transparent proxy](#transparent-proxy) |
| You run a coding agent — Claude Code, Codex, or Gemini CLI | [Agent CLI & hooks](#agent-cli-hooks) |
| You control the request lifecycle in your own code | [Direct API](#direct-api) |
| You need the lowest latency for a high-throughput service | [gRPC](#grpc) |

## Direct API

Inspect any text before it reaches your LLM. Returns a verdict (allow / flag / block) plus sanitized text.

### cURL

```bash
curl -s -X POST https://your-firewall.example.com/v1/inspect \
  -H "Authorization: Bearer $SFW_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input_text": "Email john.smith@acme.com about the invoice.",
    "system_goal": "You are a customer support agent."
  }'
```

### Python

```python
import os, httpx

resp = httpx.post(
    "https://your-firewall.example.com/v1/inspect",
    headers={"Authorization": f"Bearer {os.environ['SFW_KEY']}"},
    json={
        "input_text": user_message,
        "system_goal": "You are a customer support agent.",
    },
)
verdict = resp.json()
if verdict["action"] == "block":
    raise ValueError("Input blocked by firewall")
safe_text = verdict["sanitized_input"] or user_message  # PII pseudonymized
```

### TypeScript

```typescript
const resp = await fetch("https://your-firewall.example.com/v1/inspect", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SFW_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ input_text: userMessage }),
});
const verdict = await resp.json();
if (verdict.action === "block") throw new Error("Blocked by firewall");
const safeText = verdict.sanitized_input ?? userMessage;
```

### Full PII round-trip (anonymize → model → de-anonymize)

```python
import os, httpx

fw = httpx.Client(base_url="https://your-firewall.example.com",
                  headers={"Authorization": f"Bearer {os.environ['SFW_KEY']}"})

# 1. Inspect at the trust boundary. Real PII → structurally valid pseudonyms.
verdict = fw.post("/v1/inspect", json={"input_text": user_message}).json()
if verdict["action"] == "block":
    raise ValueError("Input blocked by firewall")
safe_text = verdict["sanitized_input"] or user_message
mapping = verdict["pii_mapping"] or {}   # {pseudonym: original}

# 2. The model only ever sees pseudonyms — real values never leave your process.
reply = call_your_llm(safe_text)

# 3. Restore real values before the user (or the next tool) sees the reply.
answer = fw.post("/v1/deanonymize",
                 json={"text": reply, "pii_mapping": mapping}).json()["text"]
```

> **The mental model:** inspect on the way in and de-anonymize on the way out. Real PII is replaced with pseudonyms before the model sees it, so it never leaves your process; the `pii_mapping` (`{pseudonym → original}`) lets you restore the real values in the model's reply. Because the pseudonyms are structurally valid (real-looking names, 555 phones, TEST-NET IPs), the model reasons about them correctly instead of hallucinating around a `<PERSON_1>` placeholder. Hold the mapping only as long as you need it — the proxy and hook paths do this round-trip for you automatically.

### Response shape (the verdict)

```typescript
// POST /v1/inspect and /v1/inspect/response both return this:
type Verdict = {
  action: "allow" | "flag" | "block";        // the recommended decision
  confidence: number;                          // AGREEMENT, not severity: how many
                                               // channels corroborate — 0.5 one, 0.75
                                               // two, 1.0 three+ (a clean verdict is 1.0)
  issues: {
    type: "pii" | "injection" | "drift";
    severity: "low" | "medium" | "high" | "critical";  // may be escalated one rank
                                               // when >=2 attack channels corroborate
    detail: string;                            // human-readable reason
    span?: { start: number; end: number };     // char offsets (raw view only)
    view?: string;                              // normalized view that caught it, e.g. "base64-decoded"
    channel?: string;                           // detector that fired: "classifier",
                                               // "semantic_drift", "session_pressure", "coercion", …
  }[];
  sanitized_input: string | null;              // PII pseudonymized; null if none found
  pii_mapping: Record<string, string> | null;  // { pseudonym: original }
  processing_time_ms: number;
};

// POST /v1/deanonymize returns:
type Deanonymized = { text: string };
```

> **This shape applies to the direct API only.** The transparent proxy does **not** wrap responses in a verdict — it returns your provider's native response body (OpenAI / Anthropic), byte-for-byte except PII restored in the content, so your existing SDK types keep working unchanged. A blocked request comes back as a provider-shaped **error**, not a `Verdict`.

## Drift baseline (system goal)

Semantic-drift detection compares each input against the LLM's intended `system_goal`. How it's set depends on the integration:

- **Direct API** — pass `system_goal` in the request body (optional; shown above).
- **Transparent proxy** — auto-derived from the `system` message in your request; nothing to add.
- **Agent hooks** — hooks send no system prompt, so set a **System goal** on the key's **Configuration** tab; it applies automatically.

A request-supplied `system_goal` always wins; the key/tenant value is the fallback — so setting it once on the key covers hooks and any request that omits its own.

## Transparent proxy

Point your existing OpenAI / Anthropic SDK at the firewall. Zero code changes beyond the base URL — PII is sanitized on the way in and restored on the way out.

**Two keys are in play.** Your firewall key (`$SFW_KEY`) authenticates you to the firewall and is stripped before the request leaves — it never reaches the provider. Your provider key rides in the SDK's normal auth header and is forwarded untouched; the firewall never stores it.

| Provider | Firewall key (stripped) | Provider key (forwarded) |
| --- | --- | --- |
| OpenAI | `X-SFW-Key` | `Authorization: Bearer sk-…` |
| Anthropic | `X-SFW-Key` | `x-api-key: sk-ant-…` |

Prefer `X-SFW-Key` for the firewall key so your provider key keeps sole use of `Authorization`. (You can also send the firewall key as `Authorization: Bearer sfw_…` on the direct API, where there is no upstream key to collide with.)

### OpenAI SDK (Python)

```python
import os
from openai import OpenAI

client = OpenAI(
    base_url="https://your-firewall.example.com/proxy/openai/v1",
    default_headers={"X-SFW-Key": os.environ["SFW_KEY"]},
    # api_key stays your real OpenAI key — forwarded to OpenAI untouched
)
client.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": "Summarize this ticket from John Smith..."}],
)
```

### Anthropic SDK (Python)

```python
import os
from anthropic import Anthropic

client = Anthropic(
    base_url="https://your-firewall.example.com/proxy/anthropic",
    default_headers={"X-SFW-Key": os.environ["SFW_KEY"]},
)
client.messages.create(
    model="claude-haiku-4-5-20251001",
    max_tokens=1024,
    messages=[{"role": "user", "content": "Summarize this ticket from John Smith..."}],
)
```

### Any OpenAI-compatible provider (OpenRouter, Requesty, Bedrock gateways…)

```bash
# Override the upstream with X-Upstream-Url
curl -s -X POST https://your-firewall.example.com/proxy/openai/v1/chat/completions \
  -H "X-SFW-Key: $SFW_KEY" \
  -H "X-Upstream-Url: https://openrouter.ai/api" \
  -H "Authorization: Bearer $OPENROUTER_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"...","messages":[{"role":"user","content":"..."}]}'
```

> The firewall returns a provider-shaped error if it blocks a request, so your SDK surfaces it as a normal API error. Streaming (`stream: true`) works too — pseudonyms are restored across SSE chunks. Every response is also screened for signs of a compromised model (jailbreak personas, leaked system prompts or credentials) and flagged — or blocked, if you raise the output policy.

## Multi-turn session tracking

Catch slow, benign-per-turn attacks (recon → capability enumeration → extraction) that look harmless one message at a time. Pass a stable conversation id and the firewall carries a decaying injection-pressure signal across the session.

Without a session id, inspection is fully per-turn (no cross-turn memory) — existing integrations are unchanged. With one, a turn that trips a real attack signal raises the session's **pressure**, so a later finding in the same conversation is corroborated instead of judged in isolation.

### Transparent proxy — X-SFW-Session-Id header

```bash
curl -s -X POST https://your-firewall.example.com/proxy/openai/v1/chat/completions \
  -H "X-SFW-Key: $SFW_KEY" \
  -H "X-SFW-Session-Id: conv-8f3a2b" \
  -H "Authorization: Bearer $OPENAI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-4o","messages":[{"role":"user","content":"..."}]}'
```

### Direct API — session_id field

```bash
curl -s -X POST https://your-firewall.example.com/v1/inspect \
  -H "Authorization: Bearer $SFW_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input_text": "<the new user turn>",
    "session_id": "conv-8f3a2b"
  }'
```

> **Inspect the new turn, not the whole history.** Send the firewall the latest user turn and let the session id carry the multi-turn signal. Re-sending the full accumulated conversation every request makes the firewall re-inspect earlier (possibly malicious) turns each time — which can block a benign new turn as collateral because a prior turn in the same blob was an attack. The session id is how you get multi-turn coverage without that false positive. This advice is for `/v1/inspect` only — on the proxy path, keep sending your normal full message history: the Agent Security goal-alignment judge reads the user's goal from the user turns in the request, so client-side trimming of user turns weakens it (it falls back to graded taint).

> Session ids are scoped per key + tenant (the same string can't collide across keys) and validated (≤ 200 chars, no `|` or control characters); invalid values are ignored, never an error. Tune the accumulator — half-life, TTL, thresholds, session cap — via `engines.session_pressure` in your firewall config.

## Agent Security — the action gate

For tool-using agents: untrusted tool OUTPUT (a web page, an email) can carry an injected instruction. The action gate catches a high-risk tool CALL taken after untrusted data entered the session — the indirect-injection exfiltration path (search → send_email), decided by provenance. Two layers (goal-alignment + graded taint) then decide how hard to act, so a legit "research then email a summary" flow isn't over-blocked.

The gate reasons about two axes per tool: **output-trust** (can this tool's result be attacker-controlled? — `web_search`, `read_email` = untrusted) and **action-risk** (does calling it *do* something consequential? — `send_email`, `delete_file` = high). It blocks only the dangerous composition: a HIGH-risk action after an UNTRUSTED observation tainted the session. Needs the same `X-SFW-Session-Id` as multi-turn tracking.

### Declare your tools under your key (once, at startup)

```bash
curl -s -X POST https://your-firewall.example.com/v1/tools \
  -H "Authorization: Bearer $SFW_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "tools": {
      "web_search": { "output_trust": "untrusted", "action_risk": "low" },
      "read_email": { "output_trust": "untrusted", "action_risk": "low" },
      "send_email": { "output_trust": "trusted",   "action_risk": "high" }
    },
    "default": { "output_trust": "untrusted", "action_risk": "low" }
  }'
# GET /v1/tools to read them back · DELETE /v1/tools to clear
```

> Registration is **under your API key** (envelope), not sent inline with each request (content) — so injected content can't downgrade `send_email` to low-risk to slip the gate. Per-key declarations override the operator's registry; an undeclared tool defaults to untrusted-output (so it taints) and low-risk (so it's never blocked) — declare a tool **high-risk** only when you want it gated.

### Make it actually engage — the checklist

1. **One-time setup —** enable it server-side (`engines.agent_security.enabled: true`, off by default). Once wired, toggle it per key under **Configuration**.
2. **One-time setup —** register your tools under your key (above) **before any proxied agent turn**. This is a prerequisite, not an add-on: until a tool is registered high-risk, it uses the untrusted-output + low-risk default, so **nothing is gated**.
3. **Per request —** send `X-SFW-Session-Id` on the proxied turns that make tool calls; the gate correlates observations and actions by session. No session id → nothing to gate against.
4. **Per request —** deliver tool results as **structured** `tool` / `tool_result` messages (not flattened into a `user` message). This is how the firewall identifies an observation and taints the session.
5. **Per request —** keep the user's earlier turns in the proxied message history. The goal-alignment judge authorizes a high-risk action against the **user turns present in the request** — the original ask ("research X and email me a summary") is usually stated turns before the tool call. Trimming user turns client-side is safe (the judge abstains and graded taint decides) but forfeits the "legit flow passes" benefit.

> **Two ways it silently does nothing — check both.** **(a) Flattened tool results:** if your platform concatenates tool output into a user message, the firewall sees plain user text, nothing taints, and every action is allowed — it must arrive as a role-tagged tool message (step 4). **(b) Streaming:** the gate runs on **non-streaming** responses only (streaming tool-call reassembly is a follow-up). Since `stream: true` is the SDK default, a streaming tool-call turn is **not gated**. For real coverage, either don't stream tool-call turns, or set `agent_security.reject_streaming: true` to fail closed (reject streaming requests while the tool is active) instead of passing them through.

> **What a block looks like.** A gated action comes back as a provider-shaped `403` (like an input block, with the `firewall` extension and `X-SFW-Trace-Id`) — handle it as a normal API error / fallback. A flagged action is **not** blocked: it returns normally with an advisory `X-SFW-*` header for your logs.

### Two layers decide how hard the gate acts (so legit flows aren't over-blocked)

Provenance (above) decides *whether* a high-risk action is suspect. Two layers then decide *how hard* to act, so the classic false positive — "research a topic, then email me a summary" — isn't blocked outright the way v1 blocked it.

1. **Goal-alignment (primary).** For a high-risk action in a tainted session, an LLM judge asks: *does this action serve what the user actually asked for?* The flow the user requested (email the summary to the person they named) is **allowed**; an exfil the user never asked for (send to an address that only appeared in a tool result) is **blocked**. Fail-safe: any judge error/timeout is treated as "can't tell," never as "aligned" — a failed judge can't auto-allow an exfil. The judge sees the tool call's arguments **in full**, never truncated; an action whose arguments exceed the verifiability bound (100k chars) is refused outright and **blocked** as unverifiable, so padding an action can never soften the outcome. Configure it under `agent_security.goal_alignment` (needs an API key). It can also be toggled per tenant and per key (inherit / on / off) in the **Configuration** tab, without touching the operator YAML.
2. **Graded taint (supporting).** Each untrusted observation is content-scanned to set taint *severity*, never existence: a clean read taints **LOW**, a read carrying a likely injection taints **HIGH**. When the judge is off or ambiguous, HIGH taint **blocks** and LOW taint only **flags** (advisory). Opt in with `agent_security.graded_taint.enabled` (off by default — enable it together with the judge). Like the judge, it can also be toggled per tenant and per key (inherit / on / off) in the **Configuration** tab.

> **Pick a posture deliberately.** The strongest is **graded taint on + goal-alignment on**: legit flows pass, exfil blocks. **Graded taint on + judge off** favors UX — a scanner-missed injection drops to LOW and only *flags* (the action still runs), so run the judge in production. **Graded taint off (the default)** keeps the v1 posture: every untrusted read taints HIGH, so any high-risk action in a tainted session is blocked (conservative, more false positives). Streaming and a missing `X-SFW-Session-Id` still bypass the gate regardless of layer — see the two silent-failure modes above.

## Agent CLI & hooks

Install the dependency-free `sfw` client straight from this firewall — no Python needed — then guard Claude Code, Codex, or Gemini with pre/post tool-call inspection.

### 1. Install the client (macOS / Linux)

```bash
curl -fsSL https://your-firewall.example.com/install.sh | sh -s -- --key "$SFW_KEY"
```

> The installer bakes this firewall's URL and your key into `~/.semantic-firewall/config`, so the commands below need no further setup. Omit `--key` if the firewall doesn't enforce auth. On Windows, download `https://your-firewall.example.com/cli/sfw-windows-amd64.exe` and add it to your PATH.

### 2. Install hooks for your agent

```bash
sfw hook install claude-code    # or: codex, gemini
sfw hook install claude-code --block-on-flag   # stricter: block flagged inputs too
sfw hook install claude-code --fail-closed     # block if the firewall is unreachable
sfw hook status
```

> **Fail mode.** By default hooks **fail open** — if the firewall is unreachable the tool call proceeds (availability first). Add `--fail-closed` to **block** instead (assurance first); only the enforcing PreToolUse / passthrough hooks honor it. Recommended policy: **block critical · flag high · allow the rest** — tune it per key in the **Configuration** tab.

### Verify the whole integration (one command)

```bash
sfw verify                        # human report: key, policy, PII, output
sfw verify --json                 # machine-readable — for agents / CI
sfw verify --proxy openai         # also test that your provider key reaches upstream
```

### Or inspect straight from the shell

```bash
echo "Email john.smith@acme.com about the invoice." | sfw inspect --format text
```

> `sfw verify` runs safe synthetic probes and reports what the firewall actually did — key recognized, a known injection blocked, PII sanitized, output screened — and exits non-zero if anything fails, so an agent or CI job can gate on it.

> Hooks inspect every tool call before it runs (blocking injections, pseudonymizing PII), every tool result after, and the agent's final response (see below). They invoke the `sfw` binary directly — no `python3` on the agent machine — and the binary is versioned to this deployment.

## Detect a compromised agent (output inspection)

Beyond inputs, the firewall screens the model's OUTPUT for signs an injection succeeded — jailbreak personas, leaked system prompts or credentials, safety-bypass confirmations. These surface as output events.

The transparent proxy screens every response automatically. In the Claude Code hooks path, install adds a `Stop` / `SubagentStop` hook that runs the agent's final message through the same check. To inspect a response yourself — from an agent you control, or a parallel watcher:

### Inspect an LLM response directly

```bash
curl -s -X POST https://your-firewall.example.com/v1/inspect/response \
  -H "Authorization: Bearer $SFW_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "response_text": "<the model reply to check>",
    "system_goal": "You are a customer support agent."
  }'
```

> Detections show in the **Activity** tab under the **Output** direction filter. Output checks are advisory by default (a false block cuts off a real answer); raise `response_action_policy` in the **Configuration** tab to enforce blocking where the topology allows it.

## Limits: text only (images, PDFs, audio pass through)

The engines inspect text. Non-text content blocks — images, audio, PDFs, and raw tool-call payloads — are forwarded to the model untouched. Inspecting a screenshot's caption is not the same as securing the screenshot.

PII or injected instructions rendered **inside** an image or PDF are invisible to the firewall — the model still reads them via vision/OCR. So a customer ticket screenshot with a real SSN, or an injection painted into an image, passes straight through.

> For text-only deployments, enable **Reject multimodal** — per key in the **Configuration** tab, or `proxy.reject_multimodal: true` globally. The proxy then returns `415 Unsupported Media Type` on any request carrying a non-text block, guaranteeing no media reaches the model — at the cost of breaking multimodal use cases. Vision/OCR inspection is planned for v2.

## gRPC

Verdicts include an `action` (allow / flag / block), the detected `issues`, and `sanitized_input` with PII replaced by structurally valid pseudonyms. What a key detects is controlled in the **Configuration** tab.

gRPC is also available for low-latency, high-throughput services — same verdict shape on port `50051`. See the project README for the `.proto` and a client example.
