mcp_direct_grants — AgentTeamsModule¶
Operator-signed direct-write grants (phase R5 of references/plans/mcp-mediated-agent-writes.plan.md). An agent
writing through the agentteams_runner MCP server is staged by default: the orchestrator approves each write. A
brief mcp_grants entry with approval: "direct" only asks for the agent's writes to land at once. That would
remove the orchestrator's look at each write, a constraint-relaxing change (Rule 15), so the runner does it only with a
verified operator-signed grant.
Source:
agentteams/mcp_direct_grants.py(integrity-pinned). Operator commands are inagentteams/cli/mcp_grant_commands.py:--sign-mcp-direct-grant,--list-mcp-direct-grantsand--revoke-mcp-direct-grant. Enforced by the runner (proposal_runner) andproposal_staging.apply_direct.
Where grants live¶
~/.config/agentteams/mcp-grants/<basename>-<hash>.json sits outside the project. It is subject to the same custody
checks as the confined_programs file: a real file, owned by you, not group- or world-writable, outside the
project. The operator's public keys live in ~/.config/agentteams/verify-keys/<key_id>.pub.pem. Under the switch,
Claude's built-in Edit and Write tools are denied both directories. A sandboxed shell (Claude's session sandbox, or
Goose's Seatbelt profile) can write only inside the project's write roots, so neither directory is writable from a
session, and a revoked grant can't be put back.
Residual risk: this depends on the session sandbox actually being merged. An orchestrator session running
unconfined could write ~/.config. The runner's direct-grants-loaded row lists the trusted key fingerprints for
the operator to check.
What a grant binds¶
Every field is Ed25519-signed, as one canonical JSON encoding, under its own purpose tag,
agentteams-mcp-direct-grant-v1. So distinct records never share a payload, and the signature can't be replayed as
any other artifact:
- grant id, agent, server, the grant-map version (runner_mcp.MAP_VERSION) and the exact tools;
- approval: direct;
- the agent's write scopes, and each team gate by a hash of its whole definition (a gate edited to always pass
voids the grant);
- the installed server's sha256;
- issue and expiry times (at most MAX_DAYS, 30 days);
- a write cap, max_writes (at most 500).
Any drift between the grant and the live brief or installed server refuses it, and re-signing is the only way back. A direct grant also needs a gate, or write scopes that name single files.
Runner enforcement¶
-
One decision point: generated agents always submit writes for staging. The runner applies a change directly only when the agent's grant verified at start. Generation can't produce a direct path, and deletions never upgrade.
-
At start: it verifies every grant, enforces the aggregate cap (
MAX_ACTIVE= 3; more fails closed), setspolicy.direct_agents, ledgersdirect-grants-loaded, and pins the file. A changed file needs a restart. - Counts from a verified ledger: at start, if any grant is active, it verifies the whole ledger and counts each
grant's
apply-directrows. A ledger that doesn't verify turns direct writes off. After that the count lives in memory, so deleting rows can't reset a grant's cap. - On every direct write: it re-checks the expiry, and checks the in-memory count against
max_writes. The ledger row records the grant id. - Visible keys:
direct-grants-loadedrecords the sha256 of each trusted operator verify key, so the operator can see which keys are trusted. - Narrower dispatches: a direct agent's nonces live
DIRECT_TTL_HOURS(4) withDIRECT_MAX_USES(10). A direct write whose nonce was issued with wider limits (before the grant loaded, say) is refused. - Deletions are never direct. An agent without a verified grant is refused direct writes whatever the policy says.
Public surface¶
| Name | Purpose |
|---|---|
PURPOSE_TAG, GRANTS_DIR, VERIFY_KEYS_DIR, MAX_ACTIVE, MAX_DAYS, MAX_WRITES, DIRECT_TTL_HOURS, DIRECT_MAX_USES |
The constants above. |
grants_file_for(root) -> Path |
The operator grants file for a project. |
signed_values(record) -> list[str] |
The exact signed payload. |
expected_binding(agent, policy, brief) -> dict |
What a grant for the agent must bind, from the live brief. |
read_grants(root) -> (records, sha) |
The custody-checked file. |
verify(record, policy, brief, *, now=None) -> dict |
Signature, binding, sunset and caps. |
active_grants(root, policy, brief) -> (agent -> grant, problems, sha) |
Every verified grant, under the aggregate cap. |
count_direct_writes(root) -> dict |
Each grant's direct writes, counted only from a verified ledger. |
check_write_allowed(grant, used, *, now=None) |
The per-write expiry and write-cap check. |
key_fingerprint(key_id) -> str |
The sha256 of a trusted verify key, for the start-up row. |
sign_record(record, private_pem, *, password=None) -> dict |
The operator signs; run outside every session. |