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 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.

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 changesTransparent proxy
You run a coding agent — Claude Code, Codex, or Gemini CLIAgent CLI & hooks
You control the request lifecycle in your own codeDirect API
You need the lowest latency for a high-throughput servicegRPC

Direct API

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

cURL

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

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

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)

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)

// 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.

ProviderFirewall key (stripped)Provider key (forwarded)
OpenAIX-SFW-KeyAuthorization: Bearer sk-…
AnthropicX-SFW-Keyx-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)

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)

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…)

# 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

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

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)

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)

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

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)

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

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

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.