The credential broker¶
SPEC §7, ADR-001. One sentence: the agent never holds the credential.
It asks QED to do something. QED decides. If the answer is yes, the qed attaches the credential on the way out.
An agent that cannot read a token cannot leak one, cannot be talked into pasting one into a chat window, and cannot keep using one after its mandate is revoked. This is also the difference between a gate and a linter: a linter tells you the action was unwise, a broker means the action was not possible.
Posture first¶
Before anything else, ask what this instance can actually enforce:
$ qed doctor
instance : qed-prod (mode enforce)
signing key: ed25519:7fbb6319282f9912
policy : 46 rules
UPSTREAM MODE INJECT CREDENTIAL
github ENFORCING header resolved (30 chars)
postgres-prod ADVISORY env unavailable: $PG_URL is not set
internal-wiki ADVISORY - not configured
1 enforcing, 2 advisory
advisory is the honest answer when QED does not hold the
credential. QED still decides and still records, but the agent is
carrying its own token and could bypass QED entirely. An upstream that
claimed to be enforcing while the agent held the credential would make the
evidence a lie, so an upstream with no resolvable credential is downgraded
whatever the config says.
With nothing configured at all, every adapter is advisory and qed doctor
says so in those words.
Configuration¶
# upstreams.yaml
upstreams:
- id: github
kind: mcp
match: { mcp_server: github }
credential:
source: env
ref: GITHUB_TOKEN
inject: { type: header, name: Authorization, format: "Bearer {value}" }
- id: postgres-prod
kind: mcp
match: { mcp_server: postgres, environments: [prod] }
credential:
source: vault
ref: "secret/data/qed/postgres-prod#DATABASE_URL"
inject: { type: env, name: DATABASE_URL }
- id: aws-prod
kind: http
match: { host_globs: ["*.amazonaws.com"], environments: [prod] }
credential:
source: vault
ref: aws/creds/qed-prod # dynamic STS, short TTL
inject: { type: sigv4 }
Matching¶
Every present constraint must hold; an absent one means "any", the same
reading as a rule's applies_to.
First match wins, in file order. Not "most specific" — specificity between a host glob and an environment list is not something anyone can rank, and a resolution order nobody can predict is worse than one they can read top to bottom. Put the narrow rule above the broad one, the way you already do with firewall rules.
host_globs matches the host, not the string: *.amazonaws.com does not
match ec2.amazonaws.com.evil.test.
Sources¶
source |
ref |
|---|---|
env |
the variable name |
file |
a path; the trailing newline echo secret > file leaves is stripped |
vault |
secret/data/path#field |
Vault is an optional extra (pip install valqore-qed[vault]). Asking for a
vault: reference without it says what to install rather than raising a
missing-module traceback — you wrote the config and deserve to be told.
A variable that is set but empty is an error, not an empty credential: that is a deployment slip essentially every time, and failing loudly beats injecting nothing.
Caching¶
A resolved credential is reused for ttl_seconds (default 60). Short enough
that a rotation or a revoked Vault lease takes effect within a minute,
long enough that a busy qed is not asking Vault on every action.
Where a credential is allowed to exist¶
In memory, for at most its TTL. That is the whole list.
- Not in the chain. Evidence records
upstream_idand nothing else. - Not in a log. The value is wrapped in a
Secretwhosestr,repr, f-string,%-format andpprintrenderings are all<redacted>, and whichjson.dumpsrefuses outright rather than serialising. - Not on disk. Nothing here writes one.
Secret.reveal() is the only way to the real bytes, and it is deliberately
awkward to type — a leak should have to be written on purpose rather than
reached by habit. There are sixteen tests in
tests/broker/test_secret.py, each one a different accident: a log line, a
traceback that formats its locals, a value nested in a dict being pretty
printed.
len(secret) and bool(secret) work, because "set but empty" is a thing a
health check needs to be able to say without printing anything.
Enforcement is derived, not asserted¶
Before the broker, an adapter was told it was enforcing by a flag. That
claim went into context.enforcement, and from there into the evidence. If
the credential was missing the claim was simply wrong, and nothing in the
chain said so.
Now QED is enforcing for a call if and only if it is holding the credential for that call. The adapter does not get asked.
$ qed mcp --config qed.yaml -- npx -y @some/mcp-server
...
qed: 4 tool call(s); 4 forwarded, ...
qed: 1 of those call(s) were ADVISORY -- decided and recorded, but QED
was not holding the credential.
The advisory count is printed separately and last, because a session that was entirely advisory looks identical to an enforcing one in every other number.
With no upstreams configured the adapter keeps whatever the flag says, which is how every deployment behaved before the broker existed — going silently advisory on an existing install would be the worse surprise.
When the credential is attached¶
| Transport | When | Why |
|---|---|---|
| stdio MCP | at spawn, into the child's environment | A child process receives its environment once. "Resolved per request" is not something the transport can offer, and claiming it would be worse than saying so. |
| HTTP | per request, as a header | Genuinely per request. |
For stdio the TTL cache governs re-spawns, not calls. A rotated secret reaches a long-lived child process when it restarts, not before.
Stripping comes first¶
An enforcing adapter removes whatever the agent supplied before attaching anything:
authorization · proxy-authorization · cookie · x-api-key · api-key
x-auth-token · x-access-token · x-amz-security-token · x-goog-api-key
Matched case-insensitively, because HTTP header names are and an agent that
capitalised AUTHORIZATION would otherwise sail straight through.
This matters more than the attaching. An adapter that added the broker's header while leaving the agent's in place would be an adapter where the agent still chooses which credential gets used.
sigv4 cannot be planned ahead, because the signature covers the request
body. The injection plan says needs_signing and the adapter refuses rather
than forwarding unsigned — forwarding unsigned would mean the agent's own
credentials get used after all.
Still to come¶
The HTTP forward proxy (qed proxy), for agents that make plain HTTP calls
rather than MCP ones. Everything it needs exists.