Metadata-Version: 2.5
Name: cbac
Version: 0.1.2
Summary: Framework-agnostic guard for CBAC: authorize an agent's action against its policy
Project-URL: Homepage, https://github.com/agent-dna/cbac-server
Project-URL: Repository, https://github.com/agent-dna/cbac-server
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: agent,authorization,guardrails,mcp,policy
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Security
Requires-Python: >=3.10
Requires-Dist: requests>=2.32.0
Description-Content-Type: text/markdown

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

```bash
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**:

```python
@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.

```python
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:

```json
{
  "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:

```python
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:

```python
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:

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