Client identity
Client identity comes from request headers, which any caller can set.
curl -H 'User-Agent: claude-cli/2.1.0' is indistinguishable from Claude Code
here, and always will be. Nothing about this is a security boundary, and no rule
built on it should be load-bearing in a threat model.
It is genuinely useful for the thing it is: knowing which of your tools is doing what, and noticing an agent nobody procured. Use it for that.
What is read
Nothing in the request path read these before; the gateway forwarded them upstream untouched and still does. The rules are ordered most specific first, and the first match wins.
| Signal | Example | Yields |
|---|---|---|
User-Agent | claude-cli/2.1.0 (external, cli) | claude-code, version 2.1.0 |
originator | codex_cli_rs | codex-cli |
User-Agent | codex_cli_rs/0.47.0 | codex-cli |
User-Agent | Cursor/1.7.3 | cursor |
x-app | cli | claude-code, when a proxy has rewritten the agent string |
anthropic-beta | claude-code-20250219 | claude-code |
x-stainless-lang + x-api-key | python | anthropic-sdk, family python |
x-stainless-lang alone | js | openai-sdk, family js |
X-OpenAI-Client-User-Agent | {"bindings_version":…,"lang":"python"} | openai-sdk |
editor-version | vscode/1.9x | copilot |
| nothing recognised | — | unknown |
Canonical names are lowercase and stable, because a rename would silently change
what an existing policy means: claude-code, claude-desktop, codex-cli,
cursor, kiro, copilot, continue, aider, anthropic-sdk, openai-sdk,
langchain, curl, unknown.
Why the order matters
Claude Code sends the Anthropic SDK’s Stainless headers as well as its own
agent string. A rule table that checked the SDK first would report every Claude
Code session as anthropic-sdk, and an organisation would never see the agent it
actually wanted to govern. Specific first, generic last.
Stainless generates both vendors’ SDKs, so its headers say which language but
not which vendor. The vendor is inferred from which authentication header the
SDK chose: only the Anthropic SDK sends x-api-key by default.
Rules
# Record anything that is not one of our two supported agents.
vulnetix ai-firewall policy guardrail supported-agents \
--rule-type client_allow --action flag --pattern 'claude-code' \
--priority 110 --enable
vulnetix ai-firewall policy guardrail supported-agents-codex \
--rule-type client_allow --action flag --pattern 'codex-cli' \
--priority 110 --enable
unknown is a name rather than an absence, which is what makes “only our
supported agents” expressible at all — an unrecognised client fails an allowlist
that does not name it.
client_deny and client_allow accept block and flag only. strip is
rejected: there is nothing in the request to remove.
An allowlist here is easy to get wrong. Your organisation almost certainly runs
more clients than it thinks: a CI script using the OpenAI SDK, a notebook, a
curl in a runbook. Every one of them is refused the moment you enable a
client_allow rule at block.
The shipped blueprint for this ships disabled for exactly that reason. Enable
it at flag, read a fortnight of inventory, then decide.
What this is good for
Attribution. “Which agent triggered these forty blocked requests” is a question you cannot answer without it, and it turns an incident from a hunt into a lookup.
Finding unmanaged agents. A client name your organisation does not recognise appearing in the inventory is worth a conversation, whether or not you ever write a rule about it.
Rollout sequencing. Rolling a strict tool policy out to claude-code first
and everything else later is a reasonable thing to want, and this is how you
express it.
What it is not good for
Stopping a determined party. Setting a header is one flag. If your requirement is “only approved software may reach the provider”, the mechanism for that is the Vulnetix API key and who holds it, not this.
Related
- The threat model — what the firewall does and does not guarantee.
- The inventory — where client names show up alongside what they carry.