mercury-agent 0.4.27 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,7 +19,7 @@ Mercury is a personal AI assistant that lives where you chat. It connects to Wha
19
19
 
20
20
  - **[Node.js](https://nodejs.org/)** >= 18 — Required for `npm install -g mercury-agent`
21
21
  - **[Bun](https://bun.sh)** >= 1.0 — JavaScript runtime used by Mercury
22
- - **[Docker](https://docs.docker.com/get-docker/)** — Required for running agent containers
22
+ - **[Docker](https://docs.docker.com/get-docker/)** — Required for running agent containers. On Linux, also install `docker-buildx` (`apt-get install docker-buildx`) — Docker Desktop includes it, but Docker Engine on Linux does not.
23
23
  - **Windows users:** Mercury runs best under [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install). Install WSL2 with `wsl --install`, then install Bun and Docker inside it.
24
24
 
25
25
  ## Quick Start
@@ -28,3 +28,7 @@
28
28
  ## Extension Points
29
29
 
30
30
  > Where the system is designed to be extended — plugin interfaces, hook points, configuration surfaces.
31
+
32
+ - **Applicative profiles** (`src/core/profiles.ts`) — a `mercury-profile.yaml` declares `capabilities`, an exhaustive `member_permissions` set, and a `system_prompt`. `applyProfile` validates capabilities and persists `.mercury/active-profile.json`; `main.ts` activates it at startup (project-wide). Member permission scoping is in `getRolePermissions` (`src/core/permissions.ts`). Authoring contract: `docs/authoring-profiles.md`.
33
+ - **Host-side capability broker** — extensions register `mercury.capability(name, handler)` (`src/extensions/types.ts`, `api.ts`); the agent invokes them from the container via `mrctl capability <name> <action>` → `POST /api/capability/:name/:action` (`src/core/routes/capability.ts`). Handlers run on the host with credentials that never enter the container; identity is the token-derived caller.
34
+ - **Caller-bound capability token** (`src/core/caller-token.ts`) — per-turn HMAC token minted at container spawn, made authoritative over spoofable identity headers in the control-plane API auth middleware (`src/core/api.ts`).
package/docs/ROADMAP.md CHANGED
@@ -26,7 +26,8 @@ Single source of truth for priority. Rule: ≤15 min/week to maintain.
26
26
  |---|------|-----|-----------|---------|
27
27
  | 1 | **autonomous-planning-tier** | [doc](backlog/autonomous-planning-tier.md) | — | Let agents decompose ideas, keep human gate |
28
28
  | 2 | **dm-auto-space** | [doc](backlog/dm-auto-space.md) | — | Auto-create space per customer DM |
29
- | 3 | **applicative-profiles** | [doc](backlog/applicative-profiles.md) | | Generic profile layer for business logic scoping |
29
+ | 3 | **applicative-profiles** | [doc](backlog/applicative-profiles.md) | caller-bound-capability-token | Generic profile layer; host-side broker keeps secrets off customer containers |
30
+ | 4 | **caller-bound-capability-token** | [doc](backlog/caller-bound-capability-token.md) | — | Per-turn token so brokers can trust callerId; also hardens TradeStation |
30
31
 
31
32
  ## Later
32
33
 
@@ -0,0 +1,270 @@
1
+ # Applicative Profiles
2
+
3
+ **Status**: Done
4
+ **Slug**: applicative-profiles
5
+ **Created**: 2026-07-01
6
+ **Last updated**: 2026-07-01
7
+
8
+ ---
9
+
10
+ ## Goal
11
+
12
+ Mercury's existing profile system (`src/core/profiles.ts`) is a static provisioning mechanism — it bundles extensions, AGENTS.md, env vars, and config defaults into a deployable template. It has no runtime behavior: it can't scope which capabilities members access, keep secrets out of an untrusted member's container, or enforce per-user ownership. Business workflows (appointment booking, customer management) require deterministic logic that the LLM cannot be trusted to enforce.
13
+
14
+ Applicative Profiles extend the profile system with a **runtime contract**: a profile declares the capabilities it wraps, the commands it exposes to members, and its member permission set — and its privileged work runs **host-side**, behind an authenticated broker, so credentials never enter a customer-controlled container. Mercury (public repo) provides the schema, permission scoping, and broker mechanism; the actual business profiles live in a **separate private repo**. The barber-appointments profile is the first implementation and the design driver.
15
+
16
+ ### Security framing (why the naive design fails)
17
+
18
+ Mercury's security model today **trusts whoever it hands a tool to** — appropriate because tool-callers have historically been admins. Two facts make a naive "wrap GWS in an extension" approach unsafe for customer-facing profiles:
19
+
20
+ 1. **The permission guard is bypassable.** It's a regex denylist on CLI names (`src/extensions/permission-guard.ts`) that the project documents as defense-in-depth only (`docs/TODOS.md` TODO-2). It cannot stop `printenv`, `cat /proc/self/environ`, `curl`, `python -c`, etc.
21
+ 2. **Member role does not remove bash.** Role only controls which CLIs are denied and which env vars are injected (`src/core/runtime.ts:1343-1376`); whether bash exists is decided by *model capability*, not role. So a member on a tool-capable model can read any env var in their container.
22
+
23
+ Therefore **any credential injected into a customer's container is readable by that customer.** The only safe design keeps the credential on the host — which Mercury already does for TradeStation (see Architecture).
24
+
25
+ ## User Stories
26
+
27
+ - As a Mercury operator, I want to install a profile from an external repo so my bot gains domain-specific business logic without modifying Mercury core.
28
+ - As a barber shop owner, I want customers (DM members) to book and cancel only their **own** appointments, never seeing others' schedules.
29
+ - As a barber shop owner, I want customers to have **zero** access to my Gmail/Google account, even though the profile uses my Google credentials for Calendar internally.
30
+ - As a profile developer, I want a clear schema + a host-side broker pattern so I can enforce ownership on the host where the real `callerId` is trustworthy.
31
+ - As a Mercury admin, I want raw capability extensions (GWS, web search) to stay admin-only while the profile's scoped commands are member-facing.
32
+
33
+ ## MVP Scope
34
+
35
+ **In scope (all in `mercury-public`):**
36
+ - Extend `mercury-profile.yaml` schema with runtime fields: `capabilities` (required host-side extensions), `member_permissions` (exhaustive member permission set), `system_prompt` (agent persona)
37
+ - Profile-aware permission resolution — when a profile is active, member permissions come from the profile, not from extension defaults
38
+ - Capability-dependency validation — activation fails if a required capability isn't installed
39
+ - A **host-side capability broker** convention: profile capabilities execute on the host (credentials in host storage), invoked from the container via the existing `mrctl`→control-plane-API channel — following the proven TradeStation pattern
40
+ - Depends on [[caller-bound-capability-token]] so the broker can trust `callerId` for ownership checks
41
+
42
+ **Out of scope (deferred):**
43
+ - The actual barber business logic (availability rules, 2-barber support, per-number appointment limits) — lives in the **private profiles repo**, not here
44
+ - Profile marketplace/registry — profiles are installed manually (git clone / package) for now
45
+ - Profile versioning/migration — no schema-version negotiation yet
46
+ - Multi-profile per space — one active profile per deployment for MVP
47
+ - Profile UI in dashboard — managed via config/YAML only
48
+ - Declarative ownership rules — ownership is enforced in the profile's host-side route code using the trusted `callerId`, not a generic schema (too domain-specific for v1)
49
+
50
+ ---
51
+
52
+ ## Context for Claude
53
+
54
+ - `src/core/profiles.ts` — Existing profile system: `profileSchema`, `loadProfileFromDir()`, `applyProfile()` (project-level, no DB/space). Extended here
55
+ - `src/core/permissions.ts` — `getRolePermissions()`, `registerPermission()`, `resolveRole()`. Role-based (admin/member) per space
56
+ - `src/core/runtime.ts` — Container spawn: denied-CLI computation + per-permission env injection at 1343-1376
57
+ - `src/extensions/permission-guard.ts` — Bypassable CLI-name denylist (see `docs/TODOS.md` TODO-2)
58
+ - `src/core/routes/tradestation.ts` + `src/tradestation/host-api.ts` — **The reference broker.** Container calls `mrctl tradestation order` → host route executes with a token from `db.getExtState(...)`, never exposing it to the container ("keeps order placement off the agent container"). Includes a caller-bound confirm guard (153-158, 245-254)
59
+ - `src/core/api.ts` — Control-plane API + auth middleware (35-75) that broker routes mount onto
60
+ - `src/cli/mrctl.ts` — In-container CLI that calls the host API (the container→host transport)
61
+
62
+ ---
63
+
64
+ ## Architecture & Data
65
+
66
+ ### The four parts (and where each lives)
67
+
68
+ | Part | Repo | What |
69
+ |------|------|------|
70
+ | 1. Profile schema + permission scoping | `mercury-public` | New `mercury-profile.yaml` fields; profile-aware member permissions |
71
+ | 2. Host-side capability broker | `mercury-public` | Profile capabilities run host-side via `mrctl`→API, à la TradeStation; secrets stay on host |
72
+ | 3. Per-turn caller token | `mercury-public` | [[caller-bound-capability-token]] — trustworthy `callerId` on the host (dependency) |
73
+ | 4. Barber business logic | **private repo** `tagula-agents/tagula-agent-profiles` | Availability, 2 barbers, per-number limits, ownership rules — the moat |
74
+
75
+ ### Part 1 — Schema + permission scoping
76
+
77
+ Extend `profileSchema` in `src/core/profiles.ts` (all new fields optional → backward compatible):
78
+
79
+ ```typescript
80
+ const profileSchema = z.object({
81
+ // ... existing: name, description, version, agents_md, extensions, env, defaults ...
82
+ capabilities: z.array(z.string()).default([]), // required host-side capability extensions, e.g. ["gws"]
83
+ member_permissions: z.array(z.string()).optional(), // EXHAUSTIVE member permission set when active
84
+ system_prompt: z.string().optional(), // profile persona
85
+ });
86
+ ```
87
+
88
+ Activation is **project-wide** (a deployment runs one profile — the "app"), stored in `space_config` on `main` / applied to member-facing spaces:
89
+ - `profile.active` — profile name
90
+ - `profile.member_permissions` — comma-separated list from the manifest
91
+
92
+ Permission resolution (`getRolePermissions()`): when `profile.member_permissions` is set, that is the **complete** member set — no extension defaults merged in. Admin/system unchanged (all). Consequence: raw `gws` is absent from the member set, so its CLI is denied **and** its env vars are withheld from member containers (`runtime.ts:1360`).
93
+
94
+ **`system_prompt` precedence** (highest wins): per-space `system_prompt` override → profile `system_prompt` → global AGENTS.md.
95
+
96
+ ### Part 2 — Host-side capability broker (the core security mechanism)
97
+
98
+ This is the crux, and it reuses an existing, production-proven pattern rather than inventing one.
99
+
100
+ **How TradeStation already does it (the template):**
101
+ - Container runs `mrctl tradestation order …` → `POST /api/tradestation/orders`
102
+ - The route runs **on the host** (`src/core/routes/tradestation.ts`) using an OAuth token read from host storage (`db.getExtState(...)`, `src/tradestation/host-api.ts:27`) — the token **never enters the container**
103
+
104
+ **The barber broker is the same shape, Calendar-flavored:**
105
+ - New host routes (e.g. `POST /api/barber/availability|book|cancel`, `GET /api/barber/my-appointments`) mounted on the existing API (`src/core/api.ts`)
106
+ - New `mrctl` (or thin profile-CLI) subcommands the member agent invokes
107
+ - Host route calls Google Calendar using credentials in **host storage** (`extension_state` / host env) — never injected into the customer container
108
+ - Ownership enforced host-side: the route filters/authorizes by the **token-derived** `callerId` (Part 3), so a customer can only see/cancel their own appointments
109
+
110
+ **Why Gmail is safe:** the customer container gets neither the `gws` CLI nor any Google credential. Even if the customer reads every env var in their container, there is no secret there — only a per-turn token scoped to themselves. Calendar access happens on the host; Gmail is never wired into the broker at all.
111
+
112
+ ### Part 3 — Trustworthy caller identity (dependency)
113
+
114
+ The broker's ownership checks require a `callerId` the customer cannot forge. Today `callerId` arrives as a spoofable header gated only by a shared `API_SECRET` (`api.ts:50-72`). [[caller-bound-capability-token]] mints a per-turn token bound to `{callerId, spaceId}` at spawn and makes it authoritative host-side. **This feature depends on that one landing** (or shipping together).
115
+
116
+ ### Part 4 — Barber business logic (private repo)
117
+
118
+ Lives in the private monorepo `Avishai-tsabari/tagula-agents` (`c:/code/agentic/tagula-agents`), under `tagula-agent-profiles/` (the repo also holds the cloud console). Checked out as a sibling of `mercury-public`. Contains the `mercury-profile.yaml`, the agent persona, and the host-side route implementations' business rules (availability windows, 2-barber scheduling, per-number booking limits, double-booking prevention). Consumes Parts 1-3 as the public contract; never touches `mercury-public`.
119
+
120
+ ### Example manifest (in the private repo)
121
+
122
+ ```yaml
123
+ name: barber-appointments
124
+ description: Appointment booking for a barber shop
125
+ version: 0.1.0
126
+ agents_md: ./AGENTS.md
127
+ capabilities: [gws] # Calendar via host-side broker
128
+ member_permissions: [prompt, prefs.get, barber] # barber only — NOT gws
129
+ system_prompt: |
130
+ You are a barber shop assistant. Help each customer book, view, and cancel
131
+ ONLY their own appointments. Never reveal other customers' schedules.
132
+ defaults:
133
+ trigger_patterns: always
134
+ ```
135
+
136
+ ### File & Folder Structure
137
+
138
+ | Path | New / Modified | Purpose |
139
+ |------|---------------|---------|
140
+ | `src/core/profiles.ts` | Modified | Extend `profileSchema`; validate `capabilities`; write `profile.*` to `space_config` |
141
+ | `src/core/permissions.ts` | Modified | Profile-aware `getRolePermissions()` |
142
+ | `src/config.ts` / `src/config-file.ts` | Modified | `profile` config key for activation |
143
+ | `src/core/api.ts` | Modified | Mount profile broker routes (generic mounting point) |
144
+ | `src/core/routes/` | New (per profile capability) | Host-side broker routes — pattern mirrors `tradestation.ts` |
145
+ | `tests/applicative-profiles.test.ts` | New | Permission scoping + capability validation tests |
146
+
147
+ ### Implementation Sequence
148
+
149
+ 1. **Extend profile schema** — add `capabilities`, `member_permissions`, `system_prompt` (optional).
150
+ - Read first: `src/core/profiles.ts`
151
+ - Verify: `bun run typecheck`; existing profiles still parse
152
+ 2. **Capability validation** — in `applyProfile()`, fail loudly if any `capabilities` entry has no matching extension.
153
+ - Read first: `src/core/profiles.ts`, `src/extensions/loader.ts`
154
+ - Verify: apply throws on missing capability; passes when present
155
+ - Blocker: Step 1
156
+ 3. **Activation + config** — write `profile.active` / `profile.member_permissions` to `space_config`; add `profile` config key.
157
+ - Read first: `src/config.ts`, `src/config-file.ts`, `src/storage/db.ts`
158
+ - Verify: `space_config` reflects active profile
159
+ - Blocker: Step 1
160
+ 4. **Profile-aware permissions** — `getRolePermissions()` uses `profile.member_permissions` as the exhaustive member set.
161
+ - Read first: `src/core/permissions.ts`
162
+ - Verify: member gets only profile perms; admin unchanged; `gws` CLI denied + env withheld for members
163
+ - Blocker: Step 3
164
+ 5. **Caller token dependency** — land [[caller-bound-capability-token]] (or ship together) so brokers can trust `callerId`.
165
+ - Verify: broker route reads token-derived caller; spoofed header ignored
166
+ - Blocker: separate doc
167
+ 6. **Broker route pattern** — document + provide the mounting point and a reference so a profile's host-side routes follow `tradestation.ts` (host-held creds, caller-bound authorization).
168
+ - Read first: `src/core/routes/tradestation.ts`, `src/tradestation/host-api.ts`, `src/core/api.ts`
169
+ - Verify: a sample broker route executes host-side, credential never injected into the container
170
+ - Blocker: Steps 4, 5
171
+ 7. **Tests** — scoping (member/admin/raw-capability), capability validation, activation.
172
+ - Read first: `tests/permissions.test.ts`
173
+ - Verify: `bun test`
174
+ - Blocker: Steps 1-6
175
+ 8. **Example config** — commented `profile` section in `mercury.example.yaml`.
176
+ - Verify: file contains the section
177
+
178
+ ---
179
+
180
+ ## Non-Negotiable Rules
181
+
182
+ - **Profiles are external** — no specific business profile (barber, etc.) lives in `mercury-public`. Only schema, scoping, and broker mechanism.
183
+ - **Credentials never enter a member container** — capability credentials live in host storage and are used only by host-side broker routes. This is the whole point; violating it re-opens the Gmail leak.
184
+ - **Member permissions are exhaustive** — a profile's `member_permissions` is the COMPLETE set; no extension defaults merged. Prevents capability leakage.
185
+ - **Ownership is enforced host-side with the trusted `callerId`** — never with a container-supplied argument, never by prompting the LLM.
186
+ - **Admin permissions unchanged** — admins always get all permissions; profiles scope members only.
187
+ - **Capability validation is strict** — activation fails loudly if a required capability extension is missing.
188
+ - **Backward compatible** — existing profiles and existing brokers keep working; all new fields optional; token path is additive.
189
+ - **Do not rely on the permission guard as a security boundary** — it is bypassable (TODO-2). Security comes from *not putting secrets in the container*, not from blocking CLIs.
190
+
191
+ ---
192
+
193
+ ## Edge Cases & Risks
194
+
195
+ | Scenario | Handling |
196
+ |----------|---------|
197
+ | Customer asks the agent to `printenv` / read files | Allowed — but the container holds no capability secret, only a self-scoped per-turn token. Nothing sensitive to leak. |
198
+ | Customer tries to `curl` the broker spoofing another `callerId` | Rejected — broker authorizes by the token-derived caller ([[caller-bound-capability-token]]), not the header. |
199
+ | Profile requires `gws` but it isn't installed | `applyProfile()` throws listing missing capabilities. |
200
+ | `member_permissions` lists an invalid permission | Filtered by `isValidPermission()`; warn, don't crash. |
201
+ | Admin overrides `role.member.permissions` per space | Per-space override still wins (existing behavior); profile sets the baseline. |
202
+ | Broker route needs Calendar but customer has no appointment yet | Ownership filter returns empty; booking creates a record keyed to `callerId`. |
203
+ | System-triggered task (`callerId = "system"`) hits a broker | Broker skips per-customer ownership for system caller (or per Part 3's system-token decision). |
204
+ | Existing deployment upgrades to this version | No behavior change: `member_permissions` optional; brokers additive. |
205
+
206
+ ---
207
+
208
+ ## Implementation Checklist
209
+
210
+ ### Phase 1 — Goal
211
+ - [x] Goal, User Stories, and MVP Scope written and reviewed
212
+
213
+ ### Phase 2 — Architecture
214
+ - [x] Architecture & Data section complete
215
+ - [x] Non-Negotiable Rules defined
216
+ - [x] Edge Cases covered
217
+ - [x] Context for Claude pointers filled in
218
+
219
+ ### Implementation
220
+ - [x] Step 1 — Extend profile schema (`capabilities`, `member_permissions`, `system_prompt`); swapped hand-rolled `parseSimpleYaml` for the `yaml` lib (multiline `system_prompt` support, less fragility)
221
+ - [x] Step 2 — Capability validation (`validateProfileCapabilities`, called in `applyProfile`); tests in `tests/applicative-profiles.test.ts`
222
+ - [x] Step 3 — Activation + config: `profile` config key; `applyProfile` persists `.mercury/active-profile.json`; `main.ts` reads it at startup (after extensions load) and calls `setActiveProfileMemberPermissions`
223
+ - [x] Step 4 — Profile-aware permissions: `getRolePermissions` uses the active profile's member set as exhaustive; precedence = explicit per-space override > profile > defaults; admin/system unaffected
224
+ - [x] Step 5 — Caller token dependency ([[caller-bound-capability-token]]) — implemented and committed
225
+ - [x] Step 5b — Inject `system_prompt` at container spawn (appended to `MERCURY_EXT_SYSTEM_PROMPT` in `runtime.ts`, ahead of per-space prompt)
226
+ - [x] Step 6 — Host-side capability broker: `mercury.capability(name, handler)` extension API; `POST /api/capability/:name/:action` route (auth = token-derived caller, authz = permission named after the capability); `registry.getCapability`; `mrctl capability <name> <action> [json]`
227
+ - [x] Step 7 — Tests: `tests/api.test.ts` capability broker (dispatch with token-derived caller; permission-denied → 403) + schema/scoping tests in `tests/applicative-profiles.test.ts`
228
+ - [x] Step 8 — Example config: profile + `mrctl capability` + `MERCURY_CALLER_TOKEN_KEY` notes in `mercury.example.yaml`
229
+ - [ ] `bun run check` passes
230
+ - [ ] No secrets or `.env` files committed
231
+ - [ ] No unrelated files modified
232
+ - [ ] All user stories verifiably met
233
+
234
+ ---
235
+
236
+ ## Open Questions
237
+
238
+ - [ ] Should `dm_auto_space` auto-activate the profile for new customer spaces (`dm_auto_space.profile: barber-appointments`)? — owner: TBD
239
+ - [ ] How does `callerId` map to appointment ownership — phone number as-is, or a registration/name step? — owner: profile developer (private repo)
240
+ - [ ] Broker routes: generic `POST /api/profile/:capability/:action` mounting vs per-profile route files? — owner: TBD
241
+
242
+ ---
243
+
244
+ ## Retrospective
245
+
246
+ **Residual risks / follow-ups:**
247
+ - Barber business logic (availability, 2-barber, per-number limits) lives in the private `tagula-agents` repo — out of this repo's scope — accepted
248
+ - Ephemeral caller-token key breaks `mrctl` if the API and container-runner ever run in separate processes without `MERCURY_CALLER_TOKEN_KEY` set; documented in config + `docs/authoring-profiles.md` — accepted
249
+ - `mrctl capability <name>/<action>` are not URL-encoded (path traversal only reaches other RBAC-guarded routes, no escalation) — accepted
250
+ - Duplicate capability-name registration across two extensions silently first-wins in `registry.getCapability` — accepted
251
+ - Empty `member_permissions: []` in a manifest gives members zero permissions (author's explicit exhaustive choice) — accepted
252
+
253
+ **What changed from the plan:**
254
+ - Signing key is NOT derived from `API_SECRET` (the plan's fallback) — `API_SECRET` is readable in the container, so an ephemeral host-only random key is used instead (config `callerTokenKey` for multi-process).
255
+ - Caller token split into its own delivered dependency (`caller-bound-capability-token`) — also hardened TradeStation, which reads identity from the same auth context (no code change needed there).
256
+ - Replaced the hand-rolled `parseSimpleYaml` in `profiles.ts` with the `yaml` lib (multiline `system_prompt` support + less fragility); built-in profiles still parse.
257
+ - Profile activation is project-wide via a module-level registry + persisted `.mercury/active-profile.json`, not per-space `space_config` writes (simpler, works for later-created dm-auto-space spaces).
258
+ - Broker registration model chosen with the user: generic `mercury.capability(name, handler)` + `POST /api/capability/:name/:action` + `mrctl capability`, authorized by the permission named after the capability.
259
+ - Added `docs/authoring-profiles.md` as the contract for the external profiles repo.
260
+
261
+ **Key decisions made during implementation:**
262
+ - Token = HMAC-SHA256 over `base64url(payload).base64url(sig)`, constant-time compare with length guard; TTL = container timeout + 60s.
263
+ - `getRolePermissions` uses `Array.isArray` (not `!== null`) on the active-profile member set so a malformed `active-profile.json` falls back to defaults instead of throwing in the hot path (found in session review).
264
+ - Profile schema uses plain `z.object` (no `.strict()`), so unknown manifest keys are stripped, honoring the `2026-07-01-strict-yaml-schema-crash` post-mortem (warn/ignore unknown keys, fail only on invalid values); startup `loadActiveProfile` is try/catch → null.
265
+
266
+ **Architecture impact** (update `docs/ARCHITECTURE.md` if any of these apply):
267
+ - [x] New package/module added — `src/core/caller-token.ts`, `src/core/routes/capability.ts`
268
+ - [ ] New data model or schema change (uses existing `space_config`; adds a local `.mercury/active-profile.json` file, no DB schema change)
269
+ - [x] New inter-package interaction — extension host-side capability handlers invoked via the control-plane API
270
+ - [ ] Deployment topology changed
@@ -0,0 +1,184 @@
1
+ # Caller-Bound Capability Token
2
+
3
+ **Status**: Done
4
+ **Slug**: caller-bound-capability-token
5
+ **Created**: 2026-07-01
6
+ **Last updated**: 2026-07-01
7
+
8
+ ---
9
+
10
+ ## Goal
11
+
12
+ Mercury's in-container CLIs (`mrctl` and extension CLIs) call back to the host control-plane API to perform privileged work. Today that callback authenticates with a **single shared secret** (`API_SECRET`, injected into every container) and the host **trusts the `x-mercury-caller` / `x-mercury-space` request headers verbatim** (`src/core/api.ts:50-72`, `src/cli/mrctl.ts:34-40`). Because `API_SECRET` is readable inside the container, any code running there can call the host API asserting **any** caller or space — i.e. caller identity is spoofable.
13
+
14
+ This is acceptable today because host-brokered capabilities (TradeStation, TTS) are **admin-only** — everyone who can invoke them is already trusted. It becomes a security hole the moment an **untrusted member** gets a host-brokered capability, which is exactly what [[applicative-profiles]] introduces (e.g. a barber-shop customer who must only touch their own appointments). A spoofed caller header would let one customer act as another.
15
+
16
+ This feature adds a **per-turn capability token** minted at container spawn, cryptographically bound to `{callerId, spaceId}`, that the host verifies and uses as the authoritative identity for authorization — instead of trusting request headers.
17
+
18
+ ## User Stories
19
+
20
+ - As a Mercury operator, I want host-brokered capabilities to attribute each request to the real caller so an untrusted member cannot impersonate another member.
21
+ - As a profile developer, I want a trustworthy `callerId` on the host side so I can enforce per-user ownership rules safely.
22
+ - As a Mercury maintainer, I want existing admin-only brokers (TradeStation) to stop relying on spoofable headers, closing an inherited weakness.
23
+
24
+ ## MVP Scope
25
+
26
+ **In scope:**
27
+ - Mint a per-turn token at container spawn (`src/agent/container-runner.ts`), bound to `{callerId, spaceId}` plus an expiry, injected as an env var (e.g. `MERCURY_CALLER_TOKEN`)
28
+ - Host-side verification in the API auth middleware (`src/core/api.ts`): when the token is present, derive `callerId`/`spaceId` **from the token**, not from `x-mercury-*` headers, for all authorization decisions
29
+ - HMAC signing with a host-only key (no new dependency); token is opaque to the container
30
+ - Migrate the TradeStation caller-bound confirm guard to token-derived identity (`src/core/routes/tradestation.ts:153-158, 245-254`)
31
+ - Backward compatibility: shared-`API_SECRET` path remains for system/admin flows; token is authoritative when present
32
+
33
+ **Out of scope (deferred):**
34
+ - Full JWT/JWKS or key rotation infrastructure — a single host-held HMAC key is sufficient for v1
35
+ - Multi-turn / session-scoped tokens — one token per container turn is enough
36
+ - Revocation lists — tokens are short-lived (expire with the turn)
37
+
38
+ ---
39
+
40
+ ## Context for Claude
41
+
42
+ - `src/core/api.ts` — Hono control-plane API; auth middleware at lines 35-75 (`safeCompare`, header identity resolution at 50-72). This is where token verification is added
43
+ - `src/cli/mrctl.ts` — in-container CLI; builds request headers at lines 33-41, reads `API_URL`/`API_SECRET`/`CALLER_ID`/`SPACE_ID` from env
44
+ - `src/agent/container-runner.ts` — container spawn; injects `API_URL`/`API_SECRET`/`CALLER_ID`/`SPACE_ID` at ~810-817. This is where the token is minted and injected
45
+ - `src/core/routes/tradestation.ts` — existing caller-bound guard (lines 153-158, 245-254) that currently trusts header identity; the reference consumer to migrate
46
+ - `src/config.ts` — `apiSecret` config (line 212); add the host-only signing key here
47
+
48
+ ---
49
+
50
+ ## Architecture & Data
51
+
52
+ ### Token shape
53
+
54
+ Opaque HMAC-signed token (no new dependency — Node `crypto`):
55
+
56
+ ```
57
+ token = base64url(payload) + "." + base64url(HMAC_SHA256(key, base64url(payload)))
58
+ payload = { c: callerId, s: spaceId, exp: <unix-seconds> }
59
+ ```
60
+
61
+ - `key` — a host-only secret (`MERCURY_CALLER_TOKEN_KEY`, auto-derived from `API_SECRET` if unset so no new required config). Never injected into containers.
62
+ - `exp` — short TTL (e.g. spawn time + container timeout). The token is useless after the turn.
63
+
64
+ ### Flow
65
+
66
+ 1. **Spawn** (`container-runner.ts`): mint token from `{callerId, spaceId, exp}`, inject as `MERCURY_CALLER_TOKEN`. Do **not** inject the signing key.
67
+ 2. **Callback** (`mrctl.ts`): forward `MERCURY_CALLER_TOKEN` as an `x-mercury-token` header (in addition to the existing `Authorization: Bearer API_SECRET`).
68
+ 3. **Verify** (`api.ts` middleware): if `x-mercury-token` is present, verify the HMAC and expiry; on success, set the request's authoritative `callerId`/`spaceId` from the token payload and **ignore** `x-mercury-caller`/`x-mercury-space` for authorization. If absent, fall back to today's behavior (shared secret + headers) for backward compatibility.
69
+
70
+ ### Why the token can't be forged from inside the container
71
+
72
+ The signing key never enters the container. The container holds only its **own** turn's token (valid for its own caller/space). It cannot mint a token for a different caller, and the shared `API_SECRET` alone no longer authorizes an identity claim once a route requires the token.
73
+
74
+ ### File & Folder Structure
75
+
76
+ | Path | New / Modified | Purpose |
77
+ |------|---------------|---------|
78
+ | `src/core/caller-token.ts` | New | `mintCallerToken()` / `verifyCallerToken()` HMAC helpers |
79
+ | `src/agent/container-runner.ts` | Modified | Mint + inject `MERCURY_CALLER_TOKEN` at spawn; keep key host-side |
80
+ | `src/cli/mrctl.ts` | Modified | Forward token as `x-mercury-token` header |
81
+ | `src/core/api.ts` | Modified | Verify token; prefer token identity over headers |
82
+ | `src/core/routes/tradestation.ts` | Modified | Use token-derived caller for the confirm guard |
83
+ | `src/config.ts` | Modified | Add `callerTokenKey` (derives from `apiSecret` when unset) |
84
+ | `tests/caller-token.test.ts` | New | Mint/verify, tamper, expiry, spoof-rejection tests |
85
+
86
+ ### Implementation Sequence
87
+
88
+ 1. **Token helpers** — `mintCallerToken({callerId, spaceId, exp}, key)` and `verifyCallerToken(token, key)` in `caller-token.ts`.
89
+ - Read first: `src/config.ts`
90
+ - Verify: unit tests for round-trip + tamper rejection pass
91
+ 2. **Mint at spawn** — inject `MERCURY_CALLER_TOKEN` in `container-runner.ts`; ensure the key is NOT injected (add to the env blocklist alongside `MERCURY_API_SECRET`).
92
+ - Read first: `src/agent/container-runner.ts` (~810-817, blocklist ~684)
93
+ - Verify: token present in container env; key absent
94
+ - Blocker: Step 1
95
+ 3. **Forward in mrctl** — add `x-mercury-token` header.
96
+ - Read first: `src/cli/mrctl.ts` (33-41)
97
+ - Verify: header present on requests
98
+ - Blocker: Step 2
99
+ 4. **Verify host-side** — token-preferred identity in `api.ts` middleware; header fallback retained.
100
+ - Read first: `src/core/api.ts` (35-75)
101
+ - Verify: request with valid token uses token identity; spoofed `x-mercury-caller` is ignored
102
+ - Blocker: Steps 1, 3
103
+ 5. **Migrate TradeStation guard** — derive confirm-guard caller from token.
104
+ - Read first: `src/core/routes/tradestation.ts` (153-158, 245-254)
105
+ - Verify: existing TradeStation tests pass with token identity
106
+ - Blocker: Step 4
107
+ 6. **Tests** — spoof rejection, tamper, expiry, backward-compat fallback.
108
+ - Verify: `bun test` passes
109
+ - Blocker: Steps 1-5
110
+
111
+ ---
112
+
113
+ ## Non-Negotiable Rules
114
+
115
+ - **Signing key never enters a container** — it stays host-side; only per-turn tokens are injected.
116
+ - **Token identity wins** — when a token is present and valid, `x-mercury-caller`/`x-mercury-space` are ignored for authorization.
117
+ - **Short-lived** — tokens expire with the turn; no long-lived caller tokens.
118
+ - **Backward compatible** — absent a token, existing shared-secret + header behavior is unchanged, so nothing breaks for current admin-only brokers during rollout.
119
+ - **Constant-time verification** — HMAC compare uses `crypto.timingSafeEqual` (mirror `safeCompare` in `api.ts`).
120
+
121
+ ---
122
+
123
+ ## Edge Cases & Risks
124
+
125
+ | Scenario | Handling |
126
+ |----------|---------|
127
+ | Token expired mid-long-turn | Route returns 401; treat as a transient auth failure. TTL should exceed the container timeout to avoid this. |
128
+ | System-triggered task (`callerId = "system"`) | Mint a token for `system` too, or allow the shared-secret path for system callers explicitly. |
129
+ | Rollout with old containers (no token) | Header/shared-secret fallback keeps them working until images are rebuilt. |
130
+ | Signing key unset | Derive deterministically from `API_SECRET` (documented) so no new required config. |
131
+ | Attacker replays a captured token | Bound to `{caller, space, exp}` and only valid for that caller's own actions; replay grants nothing beyond what that caller already had, and expires with the turn. |
132
+
133
+ ---
134
+
135
+ ## Implementation Checklist
136
+
137
+ ### Phase 1 — Goal
138
+ - [x] Goal, User Stories, and MVP Scope written and reviewed
139
+
140
+ ### Phase 2 — Architecture
141
+ - [x] Architecture & Data section complete
142
+ - [x] Non-Negotiable Rules defined
143
+ - [x] Edge Cases covered
144
+ - [x] Context for Claude pointers filled in
145
+
146
+ ### Implementation
147
+ - [x] Step 1 — Token helpers (`src/core/caller-token.ts`)
148
+ - [x] Step 2 — Mint at spawn (`container-runner.ts`; key added to `BLOCKED_ENV_VARS`)
149
+ - [x] Step 3 — Forward in mrctl (`x-mercury-token` header)
150
+ - [x] Step 4 — Verify host-side (`api.ts` middleware; token identity wins)
151
+ - [x] Step 5 — TradeStation guard: **no change needed** — it reads `c.get("auth")`, which now derives from the token
152
+ - [x] Step 6 — Tests (`tests/caller-token.test.ts` unit; `tests/api.test.ts` integration: token overrides spoofed header, invalid token → 401)
153
+ - [x] `bun run check` passes (one unrelated discord-adapter timeout flake under full parallel run; passes in isolation)
154
+ - [x] No secrets committed
155
+
156
+ ---
157
+
158
+ ## Open Questions
159
+
160
+ - [ ] Should `system` callers use a token or keep the shared-secret path? — owner: TBD
161
+ - [ ] TTL value — fixed, or derived from the per-turn container timeout? — owner: TBD
162
+
163
+ ---
164
+
165
+ ## Retrospective
166
+
167
+ **Residual risks / follow-ups:**
168
+ - none
169
+
170
+ **What changed from the plan:**
171
+ - **Signing key is NOT derived from `API_SECRET`.** The plan suggested deriving it when unset, but `API_SECRET` is injected into containers — the agent could recompute the key and forge tokens. Instead: an optional host-only `callerTokenKey` (env `MERCURY_CALLER_TOKEN_KEY`, in `BLOCKED_ENV_VARS`), falling back to an **ephemeral random key** generated once per host process. Zero required config; nothing forgeable from inside the container.
172
+ - **TradeStation guard needed no code change** — it already reads identity from `c.get("auth")`, which the middleware now sets from the token.
173
+ - Token injected as container env `CALLER_TOKEN` (unprefixed, like `CALLER_ID`/`API_SECRET`), forwarded as `x-mercury-token`.
174
+
175
+ **Key decisions made during implementation:**
176
+ - HMAC-SHA256 over `base64url(payload).base64url(sig)`, payload `{c,s,exp}`; constant-time compare; no new dependency (`node:crypto`).
177
+ - TTL = container timeout + 60s buffer, so a token never expires mid-turn but stays short-lived vs the permanent shared secret.
178
+ - Header path retained as fallback → fully backward compatible; existing header-based tests and old container images keep working.
179
+
180
+ **Architecture impact:**
181
+ - [ ] New package/module added
182
+ - [ ] New data model or schema change
183
+ - [ ] New inter-package interaction
184
+ - [ ] Deployment topology changed