cbac — the guard
The client half of CBAC: the code that asks whether an action is allowed. The
deciding happens in a separate service (cbac_service/ in this repo) that this
package reaches over HTTP, one call per action.
It imports none of the ML stack — requests (lazily, inside the call) and the
standard library are the whole of it, cbac.mcp included.
pip install cbac
Where the service is
Every call needs a URL to POST to. Either pass it, or set it in the environment:
| argument | environment | default |
|---|---|---|
cbac_url |
CBAC_URL |
(none — no URL fails the call closed) |
cbac_endpoint |
CBAC_PATH |
/cbac/v1/authorize |
cbac_timeout |
CBAC_TIMEOUT |
100 (seconds) |
The environment is read per call, so a process that loads its .env after
importing this package still gets it. An enforcement point that already reads
its own settings passes them as arguments and skips the environment entirely.
There is no baked-in host. With no URL from either source the call resolves to
decision="error", which every caller treats as not allowed — an agent's
actions never go to whatever host happened to be compiled in.
The result
Every entry point returns an AuthResult and never raises:
@dataclass
class AuthResult:
decision: str # "allow" | "deny" | "error"
message: str # why — the service's reason, or the failure
status_code: int | None # the service's pipeline error code
hash: str | None # the audit row's interaction_hash
"error" is this layer's own fail-closed value: no service configured, a
network failure, a response that carried no verdict. status_code and hash
are None on those paths, because there was no service-side verdict to carry
them.
Proceed only on "allow". Treating anything else as permission defeats the
point of a fail-closed gate.
Authorizing a call
authorize takes everything as arguments — for an enforcement point that
parsed the governance context off the wire (a gateway) or read it from a queue
message. Nothing is looked up ambiently.
from cbac import authorize
result = await authorize(
agent_id,
callee_name,
args,
user_intent,
description,
callee_type="mcp",
cbac_url=CBAC_URL,
cbac_timeout=CBAC_TIMEOUT,
)
if result.decision != "allow":
... # blocked — do not run the call
description is the callee's own description — a schema field, a docstring's
first line. Pass it: the service uses it as the verb phrase and falls back to
the de-snaked callee_name without it, and an enforcement point that has the
real description scores far better than one working from the name alone.
callee_type ("tool", "agent", "mcp") labels the other end of the edge
whose trust score the service updates while deciding. intent_id is your own
opaque correlation id, threaded unchanged to the audit row and its hash — CBAC
never parses it.
What goes on the wire
Mechanical facts, not a rendered sentence:
{
"agent_id": "...", "callee_name": "...", "callee_type": "tool",
"callee_description": "...", "arguments": {"repo": "acme/api"},
"user_intent": "...", "intent_id": null
}
The service phrases those into the text its scorers see, so an enforcement
point cannot word a call into a different verdict — and a port of this layer to
another language needs no port of the rendering. Argument values are stringified
first, so a datetime or a dataclass argument cannot fail the call closed on
JSON encoding.
The trust score (LHI) is folded in service-side while the decision is reached, so there is exactly one HTTP call per action and no component score ever round-trips through the client.
MCP (cbac.mcp)
Enforcement is gateway-side: the agent's MCP client points at a CBAC gateway instead of the MCP server, and its only job is to label each outgoing call. The gateway decides.
Client side:
from cbac.mcp import cbac_propagate
client = MultiServerMCPClient({...}, tool_interceptors=[cbac_propagate])
cbac_propagate attaches the ambient context as headers and nothing else — it
calls no service and can deny nothing. That context is opened once, at the
request entry point:
from cbac import cbac_context
with cbac_context(agent_id="github-worker", user_intent="Close issue #3 in acme/api"):
... # every MCP call made in here goes out labelled
It lives in a contextvars.ContextVar, never a function argument, so an LLM can
neither supply nor forge it. Without an open scope cbac_propagate is a
passthrough — the calls still reach the gateway, just unlabelled.
Gateway side:
from cbac.mcp import context_from_headers, denial_body
ctx = context_from_headers(get_http_headers()) # None when unlabelled
result = await authorize(ctx.agent_id, ..., cbac_url=CBAC_URL)
if result.decision != "allow":
raise ToolError(denial_body(result.decision, result.message))
denial_body renders {"status": "denied"|"error", "error": ...} — the same
shape every enforcement point returns, so a client cannot tell whether the
block came from an in-process gate, an interceptor, or a gateway several
hops away.
The developer holds no security logic and cannot switch the check off: deleting
cbac_propagate does not bypass the gateway, it only makes calls arrive
unlabelled. What a gateway does with an unlabelled call is the gateway's
policy — deny it outright, or decide it on policy alone with no drift signal.
The headers
| header | carries |
|---|---|
X-CBAC-Agent-Id |
whose policy applies |
X-CBAC-User-Intent |
what the user actually asked for |
X-CBAC-Intent-Id |
the caller's correlation id, when there is one |
All are percent-encoded (headers are latin-1 and size-capped; a user intent is
arbitrary UTF-8), and the intent is capped at 4096 encoded characters without
ever cutting an escape in half — a truncated intent is still a usable drift
signal, a rejected request is not. cbac_headers() produces them from the
ambient context and returns {} when governance is off, for clients whose
transport takes headers on the connection rather than per call. The intent id
is sent only when the workflow minted one, so a deployment that uses no
correlation ids puts no empty header on every call.
Trust boundary. Both values are client-supplied. user_intent is
unverifiable by anyone — only the client knows what the user asked — so CBAC
scores it as evidence (a drift signal), never as identity. agent_id decides
whose policy applies, which is exactly what a compromised client would lie
about; a gateway that cannot trust its callers should derive it from its
authenticated principal (OAuth subject, mTLS SAN, API key) and treat the header
as a fallback for a trusted network only. intent_id is neither identity nor
evidence — CBAC never parses it, only threads it to the audit row and its hash,
so a client that forges one corrupts its own trace and nothing else.
The context travels as headers because that is the only per-call channel MCP
client adapters expose today. _meta is the protocol-native place for it;
context_from_headers is the only piece that would change.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file cbac-0.1.1.tar.gz.
File metadata
- Download URL: cbac-0.1.1.tar.gz
- Upload date:
- Size: 17.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.11.25 {"installer":{"name":"uv","version":"0.11.25","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
28b6e06cd66df97a87fce4f0af18255fd3bd810afefd82dfb981a210bbdc2c82
|
|
| MD5 |
fff181f3185442dc43e207ac62cd9011
|
|
| BLAKE2b-256 |
00311442aff47c004174d6e73b467da7b32f17ac33b21640777eaca75511e255
|
File details
Details for the file cbac-0.1.1-py3-none-any.whl.
File metadata
- Download URL: cbac-0.1.1-py3-none-any.whl
- Upload date:
- Size: 17.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.11.25 {"installer":{"name":"uv","version":"0.11.25","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c13e716e0b16b0dfcb95798aa64bc49a25ca6205745cbe1441be9c03b11d9107
|
|
| MD5 |
69457676bae303418793ab9099310c48
|
|
| BLAKE2b-256 |
730785df73d81f64dc4a54a0ef633cacd9219311a8ee2a1afa23543091dc7f85
|