diff --git a/.artifacts/specs/2026-05-04-session-capabilities.md b/.artifacts/specs/2026-05-04-session-capabilities.md new file mode 100644 index 0000000..fa1c46f --- /dev/null +++ b/.artifacts/specs/2026-05-04-session-capabilities.md @@ -0,0 +1,288 @@ +# 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|||` +``` + +New v2 (additive — broker accepts both): + +``` +canonical = `claudemesh-session-attest-v2||||` +``` + +Where `` 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 ""`. + 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: 1–2 days. +- Broker change (PeerConn field, attestation v2 accept, per-verb + enforcement, dry-run mode): 2–3 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.