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,
mcp_did,
callee_name,
args,
user_intent,
description,
intent_id,
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 ("mcp_tool", "tool", "agent") labels the other end of the
edge whose trust score the service updates while deciding. mcp_did names the
MCP server the call is bound for — your own routing config, not something the
caller tells you; it is recorded on the trust row and comes back from
POST /cbac/v1/lhi-scores, but it is not part of the edge key and nothing
scores it. 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": "...", "mcp_did": "...",
"callee_name": "...", "callee_type": "mcp_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. mcp_did
travels in no header at all: the gateway routes the call, so it is the one party
that knows where the call went, and asking the client would only let it
misreport that.
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.
Metadata
Release files for cbac 0.1.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| cbac-0.1.2.tar.gz | 17.6 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| cbac-0.1.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 35.9 kB
Release files / cbac-0.1.2.tar.gz
| Download URL | cbac-0.1.2.tar.gz |
|---|---|
| Size | 17.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
aac4945f8402e2b4d5e70b37d345b9ab4c7e614b76d344194142dd3a87dcb371
|
|
BLAKE2b-256 checksum How to use checksums |
826657b3417fbc40e5cd29c2a28ae95d859f4ad33e2b420cff921d4263e8d1de
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is 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}
|
Release files / cbac-0.1.2-py3-none-any.whl
| Download URL | cbac-0.1.2-py3-none-any.whl |
|---|---|
| Size | 18.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0b9dab19ff01de4f44591d9d51ccf57ef494a97e8d45964e3b847c19695035bd
|
|
BLAKE2b-256 checksum How to use checksums |
ac571aee1659c1dad1b1323f24d11368e40d5f871cc06c6585b2ceba5294bcc2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is 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}
|
Security for cbac 0.1.2
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.