Per-Client Capability / ACL Design¶
Status: APPROVED — maintainer ruling 2026-08-22 resolved every §9 decision point as proposed (D4 in its amended read-write form). This document is the implementation contract; deviations found during implementation go back to §9. Scope: server-side hard enforcement of per-client, per-agent read/write capability. OAuth-based identity is a separate line that plugs into the seam defined here (§3.1) and is deliberately out of scope.
Implementation note: Principal, static credential resolution and request
context primitives are vendored from
mcp_common.identity.
cpersona.acl.Principal remains a compatibility export.
ACL configuration, grants, per-subject policy, OAuth verification and enforcement remain in CPersona. Each consumer owns its context instance: sharing code does not share request state or grant tables.
To update only this module from an upstream checkout, run
python scripts/sync-identity.py --target-package /path/to/cpersona/_vendored_mcp_common,
then repeat with --check and run CPersona's authentication and ACL tests.
Independent repositories are not automatically synchronized. The existing full
vendoring script also includes the module.
To update only this module from an upstream checkout, run
python scripts/sync-identity.py --target-package /path/to/cpersona/_vendored_mcp_common,
then repeat with --check and run CPersona's authentication/ACL tests.
Independent repositories are not automatically synchronized. The existing
full vendoring script also includes the module.
1. Problem¶
Authentication today is a single CPERSONA_AUTH_TOKEN. Every client that holds
it can call every tool against every agent_id, because agent_id is an
ordinary tool argument the caller picks freely.
There is no way to wire a second client as "read-only, and only for these agents". That intent can only be expressed as prose in the client's own instructions, which is honour-based and unenforced. The server must be able to say no.
Target model, as an example: client_A = {alpha: read-write, beta: read},
client_B = {beta: read-write}, enforced at the server regardless of what any
client sends.
2. Trust model¶
Defends against: an over-privileged or misbehaving authenticated client —
the wrong agent_id through a bug or prompt injection, writes from a client
wired with read-only intent, and blast-radius confinement per client.
Does not defend against: network-layer exposure (unchanged: bind guard plus bearer auth), a leaked token of a fully-granted client, or a hostile local process on the stdio transport (§5.4). Identity proof beyond shared secrets is the OAuth line's job, not this one's.
3. Core model¶
request ──► IdentityResolver ──► client_id ──► ACL[(client_id, agent_scope)] ──► allow / deny
- Principal (
client_id): an opaque string naming a client. It is resolved from the request by an identity resolver, and the rest of the system never sees credentials, only theclient_id. - Grant:
(client_id, agent_pattern) → none | read | read-write, whereagent_patternis an exactagent_idor the wildcard"*". - Permission lattice:
none < read < read-write.read-write ⊃ read. - Effective permission for
(client, agent)is the exact-match grant if one exists, else the client's"*"grant, elsenone. Exact beats wildcard even when it grants less: the operator who wrote{"*": "read", "noisy": "none"}meant the exception.
3.1 The identity seam (the load-bearing abstraction)¶
Enforcement consumes only client_id. How a client_id is established is a
pluggable resolver behind a narrow interface:
class IdentityResolver(Protocol):
def resolve(self, request) -> Principal | None:
"""Return the authenticated principal, or None for 401."""
- v1 resolver — named static tokens (§4): a bearer token →
client_idmap. - Future resolver — OAuth: token introspection or JWT validation resolving
to the same
client_idnamespace. It drops in: the grant table, the enforcement layer, and every test below it are untouched.
Hard constraint: nothing outside the resolver may assume identity came from
a static token. No bearer-specific fields on Principal, and no reads of
Authorization headers past the resolver. This constraint is what makes the
OAuth line additive instead of a rework.
4. Configuration¶
The authoritative store is a config file, not the database. The database has a bootstrap problem — who inserts the first admin credential into a store you need credentials to reach — the grant set is small and operator-authored, and file-plus-restart matches how every other CPersona knob works. The DB schema also stays untouched, which is a 2.5.x line invariant.
// CPERSONA_ACL_FILE=/path/to/acl.json
{
"clients": [
{
"client_id": "assistant-a",
"token": "${CPERSONA_TOKEN_ASSISTANT_A}", // literal or ${ENV} reference
"grants": { "alpha": "read-write", "*": "read" }
},
{
"client_id": "importer",
"token": "s3cr3t-literal-also-allowed",
"grants": { "beta": "read-write" }
},
{
"client_id": "web-connector",
"token": null, // grants only: a resolver
"grants": { "beta": "read" } // asserts this identity (§5.4)
}
]
}
tokenaccepts a literal or a${ENV_VAR}reference (resolved at load; an unset variable is a startup error — fail closed, §7).-
token: nullsays the row carries grants for a principal some resolver asserts, rather than a credential a caller presents (§5.4). It contributes no token entry, so nothing can present it.Omitting the key is not that declaration. A forgotten token stays a startup error, because a row nothing can reach is not what an operator who typed a client id meant to write. The distinction is what keeps the alternative — writing a placeholder token to get the grants in — from issuing a live credential nobody wanted, with nothing to show for it. - Duplicate
client_idor duplicate resolved token: startup error. Token lookup must stay unambiguous. - Permissions are exactly"none" | "read" | "read-write". Unknown strings are a startup error. -"per_subject": true(optional, and only valid together withtoken: null) declares the per-subject boundary of docs/OAUTH_DESIGN.md §12. Each signed-in subject arriving through that client reaches only its own alias space, whatever the grants row allows. It is a restrictive boundary evaluated before the grant table, so the deny beats every allow,"*"included.On a row that carries a static token, or on
local, the flag is a startup error: those principals name a client or a transport, never a person, so the policy could never apply. - File permissions: the loader warns when the file is group- or world-readable (the same posture as the DB file).
4.1 Two-stage default (backward compatibility)¶
| State | Behavior |
|---|---|
CPERSONA_ACL_FILE unset (default) |
Legacy mode, byte-for-byte today's behavior: CPERSONA_AUTH_TOKEN single token (or unauthenticated + reachability warning), full capability for every caller. ACL code contributes zero decisions. |
CPERSONA_ACL_FILE set |
ACL mode: named tokens only; every call is resolved and checked; anything not explicitly granted is denied. |
ACL mode is opt-in and the default behaviour is unchanged, so the release can ship additive.
In ACL mode, CPERSONA_AUTH_TOKEN is ignored with a startup warning if it
is also set (proposed; alternative in §9-D3). One authority for credentials at
a time, and a forgotten legacy token must not survive as a hidden
full-capability backdoor.
5. Enforcement¶
Two layers, both server-side.
5.1 Layer 1 — transport authentication (extends BearerTokenMiddleware)¶
Today the middleware compares one token (hmac.compare_digest) and forwards.
The change: in ACL mode it resolves the token against the client table, with a
constant-time comparison against every entry (no early exit, no
dict-by-token lookup; the table is small), and stashes the resolved Principal
in a contextvars.ContextVar for the dispatch layer. An unknown or missing
token gets 401, exactly as today.
Propagation note: the HTTP transport runs
StreamableHTTPSessionManager(stateless=True), so a tool call executes within
the ASGI request's task lineage, and the context variable set by the middleware
is visible at dispatch.
This is an implementation-detail dependency, and it gets its own wiring test (§8), so that a future transport change (sessionful mode, task pools) fails red instead of resolving every call to "no principal" and saying nothing.
5.2 Layer 2 — capability check at tool dispatch¶
The vendored ToolRegistry.call_tool seam stays untouched. It is shared across
servers, and a cross-consumer change is a different — and much more expensive —
release class.
Instead, cpersona wraps its own handlers at registration time in server.py.
After the 29 auto_tool registrations, a single pass replaces each handler
with:
guard(tool_name, handler): arguments → resolve scope → check → handler | denial
The check reads
(principal, tool_classification[tool_name], agent_scope(arguments)) and
compares it against the grant table.
The fail-closed default: a tool present in the registry but missing from the classification table is denied in ACL mode, with an explicit "unclassified tool" error. A new tool cannot ship enforcement-invisible, and §8 pins this with an exhaustiveness test.
5.3 Error contract¶
- Transport failures stay HTTP:
401for a bad or missing token, as today. - Capability denials are tool errors — the MCP call succeeds transport-wise and returns a structured refusal — because the decision needs tool arguments and happens inside dispatch:
{ "ok": false, "error": "permission_denied",
"tool": "store", "agent_id": "beta", "required": "read-write",
"client_id": "assistant-a" }
The shape above is the minimum, not the maximum. A detail string joins it
whenever the guard can name a cause the fields do not already carry: an
unclassified tool, a non-string scope argument, a call that demanded every
agent because it sent no scope at all, or a client with no entry in the grant
table.
It is deliberately absent from the example's own case, where agent_id and
required already say which agent was short of which level. Restating that in
prose would add no information.
The last of those is the only one no caller can act on, and it reads least like a configuration gap. A principal with no row authenticates, the unscoped tools answer it normally, and every scoped tool refuses: a connection that looks healthy and remembers nothing.
It is named rather than left to the scope advice, which would send that caller after a fix that cannot work, since no agent is reachable at any level.
client_id echoes the caller's own resolved identity — it is not a secret to
itself — so a mis-wired client is diagnosable from its side of the wire.
Denials are logged server-side at WARNING with the same fields, for
observability. That is also the audit seed if a real audit log is ever wanted,
and it is deliberately not built now.
5.4 stdio transport¶
The stdio transport has no credential to resolve: the peer is whoever spawned the process.
Proposed: in ACL mode, stdio resolves to the reserved principal "local", and
grants for it come from the same file ("client_id": "local", with token
either omitted or written as an explicit null — any other value is a startup
error, since it would be an entry nothing can ever present).
If "local" is absent, stdio calls are denied like any ungranted principal. An
operator who turns ACL mode on states every principal explicitly, the local one
included. In legacy mode, stdio is unrestricted, as today.
6. Tool classification and agent-scope resolution¶
Classification is an explicit table in cpersona code (ACL_CLASSIFICATION), not
derived at runtime from ToolAnnotations.
Annotations are client-facing hints; the ACL table is a server-side security decision. The two are kept honest against each other by a test (§8) rather than by sharing a source. Where they disagree, the test forces the disagreement to be examined, instead of inheriting a hint written for a different purpose and saying nothing.
Survey result, over all 29 tools from the server.py registrations: two tools
carry no ToolAnnotations today, calibrate_threshold and
set_recall_precision. Both mutate calibration state, and the gap is fixed
alongside the implementation, independent of which way §9-D6 resolves.
| Tool | Capability | Agent scope |
|---|---|---|
recall, recall_with_context, get_contents, get_profile, list_memories, list_episodes, get_recall_precision |
read | agent_id argument |
store, update_profile, archive_episode, update_memory, lock_memory, unlock_memory, delete_memory, delete_episode, delete_agent_data |
read-write | agent_id argument |
calibrate_threshold, set_recall_precision |
read-write | agent_id argument (mutate per-agent calibration state) |
check_health, deep_check |
read; read-write when fix=true |
agent_id argument; empty = every agent → requires the grant on "*" |
migrate_channel_axis |
read-write | agent_id argument; empty = every agent → "*" |
export_memories |
read-write; while CPERSONA_EXPORT_DIR is unset (the shipped default) the demand escalates to "*" — the path argument is caller-chosen anywhere on the filesystem, so the blast radius is not one agent's data (§9-D4, second amendment from the pre-merge review) |
agent_id argument |
import_memories |
read-write; same "*" escalation while CPERSONA_EXPORT_DIR is unset |
target_agent_id; empty = "as recorded in file" → "*" |
merge_memories |
copy: read(source) + read-write(target). move: read-write on both (move deletes source rows) |
source_agent_id + target_agent_id |
pause_persistence, resume_persistence |
read-write on "*" — the demand cannot depend on an argument the caller may omit, and the keyless form still silences every keyless caller's writes across every agent. A declared session_key narrows the effect to that key; it does not narrow the demand |
global |
persistence_status, get_queue_status, get_operating_context |
unscoped read: allowed for any authenticated principal (no per-agent data; §9-D5) | none |
Resolution rules the table relies on:
-
A call whose scope resolves to
"*"(an emptyagent_idon an all-agents tool) is a sweep: it touches every agent, the excepted ones included. It is satisfied only at the level every grant row allows — the minimum over the wildcard grant and every named exception.Holding
readon three named agents does not add up toreadon"*", and{"*": "read-write", "prod": "none"}cannot reachprodthrough an all-agents call: the operator meant the exception. (D6 applied to sweeps, a pre-merge review refinement. The first cut consulted only the wildcard grant, which let a sweep do what the named"none"forbade.) - An agent-scope argument that is not a non-empty string — absent, empty, or a non-string type — resolves to the wildcard demand, the broadest requirement. The guard runs outside the per-tool parameter validation, so it must not assume validated shapes (a pre-merge review refinement). - The file-I/O escalation tests only whetherCPERSONA_EXPORT_DIRis set, not how much it contains. Point it at a service-owned directory. A broad root (/home,/var) restores the agent-scoped demand while still letting a caller choose almost any path inside it, which re-opens what the escalation closed and shows nothing. - Conditional capability (fix=true) is resolved from arguments before the handler runs. The guard sees the same validated arguments the handler would.
7. Failure posture¶
Fail closed, loudly, at the earliest seam.
- A malformed ACL file, an unknown permission string, a duplicate token or
client, or an unresolvable
${ENV}: startup error. The server refuses to serve, rather than serving with a policy other than the one written. - An unresolvable principal at dispatch in ACL mode (an empty contextvar, which means a wiring regression): deny, plus an ERROR log. Never "no principal, no restriction".
- A tool missing from the classification table in ACL mode: deny (§5.2).
8. Test strategy¶
- Resolver unit: token → principal over the table, the constant-time path,
an unknown token returning
None, and${ENV}resolution and its failure. - Wiring — this extends the existing
test_253_middleware_wiring.pypattern, with real ASGI requests through the assembled app: 401 on an unknown token; a granted call passing; the same call under a lesser grant returning the §5.3 denial. That pins middleware → contextvar → guard end to end, which is also thestateless=Truepropagation proof. - Classification exhaustiveness: every name in
registry._handlersis inACL_CLASSIFICATION. A registered-but-unclassified tool fails the suite, and is denied at runtime — both halves of fail-closed. - Annotations cross-check:
readOnlyHint=True⇔ classificationread, with a reviewed explicit exception list (expected:check_healthanddeep_check, read-tools with a write mode). - Scope resolution: merge move/copy grant matrices; empty
agent_id→"*"; thefix=trueescalation. - Legacy-mode equivalence: with
CPERSONA_ACL_FILEunset, the guard contributes zero decisions — a behaviour-identical suite run. - stdio principal:
"local"granted, and absent-denied, per §5.4. - Mutation checks (release-gate discipline): drop the guard wrap → red; invert one grant → red; remove one classification row → red.
9. Decision points for the maintainer¶
Proposed defaults are what §§3–7 specify. Each is cheap to change before implementation.
| # | Decision | Proposed | Alternative |
|---|---|---|---|
| D1 | Grant store | Config file (§4) | DB table (bootstrap problem; schema change is off the table for this line anyway) |
| D2 | Default policy | Two-stage opt-in (§4.1) | ACL always on with an implicit full-grant default client (more moving parts in legacy deployments for zero gained enforcement) |
| D3 | CPERSONA_AUTH_TOKEN in ACL mode |
Ignored + startup warning | Auto-map to a built-in full-capability client (keeps one hidden super-token alive — the failure mode this feature exists to remove) |
| D4 | export_memories |
read-write. The original "read" rationale ("the file write is operator-configured, not caller-chosen") does not survive contact with _confine_io_path: with CPERSONA_EXPORT_DIR unset — the shipped default — the caller picks any ..-free absolute path (bug-054 lineage), and the existing annotation already declares destructiveHint=True |
read, valid only when CPERSONA_EXPORT_DIR confinement is actually configured — a later, condition-gated relaxation if read-only backup clients turn out to be a real need |
| D5 | Unscoped reads (persistence_status etc.) |
Any authenticated principal | Require at least one non-none grant (denies a fully-revoked-but-still-listed client even status visibility) |
| D6 | Wildcard semantics | Exact-match overrides wildcard, both directions (§3) | Wildcard as floor (max(exact, "*")) — simpler but cannot express "read everywhere except none on X" |
| D7 | Ship shape | One alpha rung (auth-surface change; soak the resolver + guard under real traffic) | Direct-to-final (formally allowed: additive, default-off) |
10. Non-goals¶
- OAuth and identity proof — a separate line, which consumes §3.1.
- project_id / channel granularity — no consumer today. The grant value is
a closed enum, so a later
{perm, projects: [...]}extension is additive. - Rate limiting, quotas, audit persistence — out of scope. The §5.3 logging is the seed, not the feature.
- Multi-tenancy beyond agent_id — the agent axis is the isolation model.