Files
claudemesh/.artifacts/specs/2026-05-04-session-capabilities.md
Alejandro Gutiérrez 96520394ff
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
docs(spec): session capabilities — first-class concept
Spec for the gap #4 follow-up from the 1.34.x triage. Builds on
2026-04-15-per-peer-capabilities.md (member-keyed recipient grants)
by adding a sender-side cap subset on session attestations: parent
member signs {session_pubkey, allowed_caps[], expires_at}, broker
enforces intersection of recipient grants × session caps on every
protected operation.

v2 attestation alongside v1 (different canonical prefix
"claudemesh-session-attest-v2|..." → no collision). Default when
no caps subset is declared = full member caps (today's behavior;
opt-in restriction, not breaking).

CLI surface: claudemesh launch --caps dm,read. Bonus: set_state
gate (state-write cap) ships in the same release — closes the
"any session can clobber shared keys like current-pr" footgun.

Migration: dry-run mode for one release before flipping
enforcement. Mirrors the original per-peer-capabilities rollout.

Estimate: ~1 sprint + 1 week dry-run window.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 21:59:18 +01:00

289 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Session capabilities — first-class concept
**Status:** spec, queued behind v0.3.0 topic-encryption work.
**Owner:** alezmad
**Author:** Claude (Sprint B follow-up, 2026-05-04)
**Related:** `2026-04-15-per-peer-capabilities.md` (existing per-peer
caps system, member-keyed), `2026-05-04-per-session-presence.md`
(per-launch session presence — what we're now restricting).
## Problem
Per-peer capability grants (`apps/broker/src/index.ts:2178+, 2309+`)
are keyed on the sender's **stable member pubkey**. The grant model
gives the recipient fine-grained control: "alice can DM me",
"bob can read state but not broadcast", etc.
But: as of v1.30.0 (`per-session-presence`), every `claudemesh
launch` mints a per-launch ephemeral keypair with a parent attestation
binding it to the member identity. The launched session inherits **all**
the member's capabilities transitively, because cap enforcement always
falls through to the member key.
Concretely:
- Member `alice` is in mesh `flexicar`, granted `dm + state-read +
state-write` by everyone.
- Alice launches a session with `claudemesh launch` to do an automated
task — say, run a Claude Code agent that iterates over PRs.
- That session has full member privileges. It can DM peers, write
shared state keys (e.g. clobber `current-pr`), grant new caps, ban
members, etc. — none of which the user wanted to delegate.
There is no way to express "this session can DM peers but cannot
deploy services or grant caps." The parent attestation is a binary
existence proof — "this session was vouched by a member" — with no
capability subset.
Plus an adjacent footgun: `set_state` (`apps/broker/src/index.ts:2949`)
has **no cap check at all**. Anyone in the mesh can write any key. The
spec at `2026-04-15-per-peer-capabilities.md` lists `state-write` as a
planned cap but it was never wired into the broker. Shared keys like
`current-pr` are write-anyone today.
## Goal
A launched session can be issued **a capability subset** of its
parent member, signed by the parent at launch time, and the broker
enforces the **intersection** of recipient grants × session caps on
every protected operation.
## Non-goals
- Changing the existing per-peer cap model. Member-keyed grants stay
authoritative for "who is allowed to talk to me."
- Cross-machine session caps (waiting on 2.0.0 HKDF identity).
- Per-tool granularity inside the Claude Code MCP surface — this
spec only covers the broker-enforceable verbs (dm, broadcast,
state-read, state-write, grant, kick, ban, profile-write,
service-deploy).
- Delegation: a session cannot re-vouch a sub-session with its own
cap subset. Only members can attest sessions. (Could be lifted in
a future spec; today's launch flow doesn't need it.)
## Design
### Capability vocabulary
Existing (today, member-level):
| Capability | Effect when GRANTED on a recipient → sender pair |
|---------------|---------------------------------------------------|
| `read` | Sender appears in recipient's `list_peers` |
| `dm` | Sender can DM recipient |
| `broadcast` | Sender's broadcasts reach recipient |
| `state-read` | Sender can read shared state |
| `state-write` | (planned) Sender can write shared state |
| `file-read` | Sender can fetch files recipient shared |
New (session-level — cap subset on the attestation):
These are the **verbs the session is allowed to invoke**, NOT what
peers can do TO it. A session attestation declaring `["dm", "read"]`
means the session can SEND dm/read-list operations; it cannot
broadcast, write state, grant, etc.
| Session cap | Gates which broker operations |
|-------------------|------------------------------------------------|
| `dm` | `send` with single recipient |
| `broadcast` | `send` with `*`, `@group`, `#topic` |
| `state-read` | `get_state`, `list_state` |
| `state-write` | `set_state` |
| `grant` | `grant`, `revoke`, `block` |
| `kick` | `kick`, `disconnect` |
| `ban` | `ban`, `unban` |
| `profile-write` | `set_profile`, `set_summary`, `set_status` |
| `service-deploy` | `mesh_service_register`, `_unregister` |
The default cap set when no subset is declared: the **full member
set** (today's behavior — opt-in restriction, not breaking).
### Attestation v2
Existing v1 (`apps/cli/src/services/broker/session-hello-sig.ts`):
```
canonical = `claudemesh-session-attest|<parent>|<session>|<expires>`
```
New v2 (additive — broker accepts both):
```
canonical = `claudemesh-session-attest-v2|<parent>|<session>|<expires>|<sorted-caps-csv>`
```
Where `<sorted-caps-csv>` is the lower-cased, comma-joined,
ASCII-sorted cap list. Empty-list = full member caps (default,
back-compat).
**Wire shape additions on `session_hello`:**
```ts
{
type: "session_hello",
...existing fields...,
parentAttestation: {
sessionPubkey,
parentMemberPubkey,
expiresAt,
signature,
// NEW:
allowed_caps?: string[], // omitted = full member set
version?: 2, // omitted = v1
},
}
```
The broker version-detects: `version === 2` → verify v2 canonical
including `allowed_caps`. Default behavior is unchanged for clients
that don't pass it.
### Enforcement
Add `allowed_caps: string[] | null` to the in-memory `PeerConn`
shape (`apps/broker/src/index.ts:131`). Populated from
`handleSessionHello` (the v2 attestation supplies it) and from
`handleHello` (control-plane / member connection — set to `null`,
meaning "full member caps").
**Effective cap check** for a sending peer needing `cap`:
```ts
function senderHasCap(conn: PeerConn, cap: string): boolean {
if (conn.allowed_caps === null) return true; // member-level, no subset
return conn.allowed_caps.includes(cap);
}
```
Wire this into every broker operation in the table above. The
existing per-peer recipient-cap check at `2178+, 2309+` stays —
session caps gate the **sender side**, recipient grants gate the
**receive side**, and both must allow:
```
allowed = senderHasCap(conn, capNeeded) && recipientGrants[sender][capNeeded]
```
### `set_state` gate (bonus, ship together)
Today: no cap check. After this spec: `set_state` requires
`state-write` on the sender side. Migration: existing members
default to having `state-write` in their member caps (no recipient
grant model for state-write — it's a sender-side gate only, mesh-
wide). New attestations can omit it to forbid the session.
The recipient-side analog (per-peer state-write grants) is left for
a future spec — today the value of guarding state-write is
session-level (avoid an automated session clobbering shared keys),
not peer-level.
### CLI surface
```
claudemesh launch --caps dm,read # tight: read-only chat agent
claudemesh launch --caps dm,broadcast # send-only, no state writes
claudemesh launch # default: full member caps
```
`claudemesh launch --caps ?` prints the table above with descriptions.
`claudemesh peer list --json` includes `allowed_caps` per row when
present (`null` = full member). Lets users audit what their running
sessions can actually do.
### Migration plan (mirrors `2026-04-15-per-peer-capabilities.md` §"Migration plan")
1. **Broker schema additive** — `PeerConn.allowed_caps` in-memory
only; no DB column. Reload-on-reconnect is fine because the
attestation is re-sent on every WS open (it's the proof of
identity).
2. **CLI ships v2 attestation alongside v1.** New `--caps` flag
defaults to omitted (= v1 attestation, full caps). Older
brokers ignore the new fields entirely.
3. **Broker accepts v2.** When `allowed_caps` arrives, store it.
No enforcement yet — log denied operations as `cap_check_dryrun`
metric counter, still allow them through.
4. **Dry-run release.** Ship one CLI + broker release that emits
the metric but doesn't enforce. Watch for false positives in
real meshes for ≥ 1 week.
5. **Flip enforcement on.** Broker rejects operations failing the
cap check with `forbidden: missing session capability "<cap>"`.
Default ("no caps declared = full member") keeps existing
sessions unaffected.
6. **`set_state` gate** ships in step 5 alongside the rest. Default
member caps include `state-write`, so flipping it on doesn't
break existing flows. Only sessions that explicitly omit
`state-write` from `--caps` lose write access.
### Crypto notes
- v2 attestation re-uses `crypto_sign_detached` over the new
canonical string; same parent member secret key, same TTL caps
(≤24 h), same `expiresAt` semantics.
- v1 signatures are NOT v2 signatures — collision is impossible
because the canonical strings have different prefixes
(`claudemesh-session-attest` vs `claudemesh-session-attest-v2`).
Domain separation is intrinsic.
- Like the existing per-peer cap system: caps are server-enforced
metadata, not capability tokens. A malicious broker can ignore
them. This is about UX trust + footgun prevention, not protocol-
level security.
## Open questions
1. **Should the session attestation also bind to a fingerprint of
the launched binary / Claude version?** Would let a member say
"this session is constrained to Claude Code v1.34.15" so a
compromised launched-binary doesn't get reused. Probably no — too
much friction for the threat model.
2. **What's the right default for `claudemesh launch` going forward?**
Once enforcement ships, do we change the default `--caps` from
"full member" to "dm + read + state-read"? Tighter but breaks
existing automation that writes state. Probably worth a one-
release deprecation warning ("your session will lose state-write
in v2.0.0 unless you pass --caps state-write") and then flip in
v2.0.0.
3. **Does `--caps` belong in `~/.claudemesh/config.json` per-mesh
defaults too?** A user who always launches read-only agents
wants `caps: ["dm", "read"]` as a personal default. Easy add;
defer until users ask for it.
4. **Per-tool MCP cap surface?** Out of scope here, but: a `claudemesh
launch --tools peer:read,memory:write` would be a finer cut than
broker-verb caps. The broker can't enforce that — it'd live in the
MCP wrapper / Claude Code's allowedTools. Different layer.
## Test plan
- Pure-logic tests on `senderHasCap` (member-level → always true,
empty caps → always false, declared caps → exact match).
- Broker integration: launch a session with `--caps dm`, attempt
`set_state` → expect `forbidden: missing session capability
"state-write"`.
- v1 attestation still accepted, no `allowed_caps` set, all caps
permitted (back-compat).
- v2 attestation with empty `allowed_caps` array → broker treats
as "explicitly empty, no caps allowed" (NOT "full member"). The
full-member default is "field omitted entirely". Test both.
- Dry-run mode: cap fail increments the counter but the operation
proceeds. Smoke-test before flipping enforcement.
## Estimate
- Spec review + open-question resolution: 12 days.
- Broker change (PeerConn field, attestation v2 accept, per-verb
enforcement, dry-run mode): 23 days.
- CLI change (`--caps` flag, attestation builder, peer list
surface): 1 day.
- Tests: 1 day.
- Dry-run release window: ≥ 1 week.
Total: ~1 sprint of focused work, plus a dry-run window.