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_xxxxxxxxfor your key,https://your-firewall.example.comfor 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 changes | Transparent proxy |
| You run a coding agent — Claude Code, Codex, or Gemini CLI | Agent CLI & hooks |
| You control the request lifecycle in your own code | Direct API |
| You need the lowest latency for a high-throughput service | gRPC |
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_goalin the request body (optional; shown above). - Transparent proxy — auto-derived from the
systemmessage 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)
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/inspectonly — 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 — viaengines.session_pressurein 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_emailto 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
- One-time setup — enable it server-side (
engines.agent_security.enabled: true, off by default). Once wired, toggle it per key under Configuration. - 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.
- Per request — send
X-SFW-Session-Idon the proxied turns that make tool calls; the gate correlates observations and actions by session. No session id → nothing to gate against. - Per request — deliver tool results as structured
tool/tool_resultmessages (not flattened into ausermessage). This is how the firewall identifies an observation and taints the session. - 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: trueis the SDK default, a streaming tool-call turn is not gated. For real coverage, either don't stream tool-call turns, or setagent_security.reject_streaming: trueto 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 thefirewallextension andX-SFW-Trace-Id) — handle it as a normal API error / fallback. A flagged action is not blocked: it returns normally with an advisoryX-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.
- 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. - 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-Idstill 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--keyif the firewall doesn't enforce auth. On Windows, downloadhttps://your-firewall.example.com/cli/sfw-windows-amd64.exeand 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-closedto 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 verifyruns 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
sfwbinary directly — nopython3on 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_policyin 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: trueglobally. The proxy then returns415 Unsupported Media Typeon 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.