mercury-agent 0.4.26 → 0.4.28

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
@@ -131,6 +131,7 @@ Each space is a user-defined memory boundary with its own workspace and pi sessi
131
131
  | **Media** | Images, documents, voice notes | [docs/media/overview.md](docs/media/overview.md) |
132
132
  | **KB Distillation** | Extract lasting knowledge from chats | [docs/kb-distillation.md](docs/kb-distillation.md) |
133
133
  | **Extensions** | TypeScript plugins for CLIs, skills, jobs, hooks | [docs/extensions.md](docs/extensions.md) |
134
+ | **DM Auto-Space** | Auto-create isolated spaces per customer DM | [docs/rate-limiting.md](docs/rate-limiting.md) |
134
135
 
135
136
  ---
136
137
 
@@ -377,6 +378,28 @@ Supported OAuth providers: Anthropic, GitHub Copilot, Google Gemini CLI, Antigra
377
378
  | `MERCURY_TRIGGER_PATTERNS` | `@Mercury,Mercury` | Trigger patterns |
378
379
  | `MERCURY_ADMINS` | — | Pre-seeded admin user IDs |
379
380
 
381
+ ### DM Auto-Space
382
+
383
+ Auto-create isolated spaces for each customer who DMs your bot. Each customer gets their own space with persistent context, configurable permissions, and rate limits.
384
+
385
+ ```yaml
386
+ # mercury.yaml
387
+ dm_auto_space:
388
+ enabled: true
389
+ admin_ids: # platform IDs — auto-link to "main" space
390
+ - "24417056866472" # WhatsApp LID, Telegram user ID, etc.
391
+ default_system_prompt: "You are a helpful assistant."
392
+ default_member_permissions: "prompt,prefs.get" # restrict customers to chat only
393
+
394
+ runtime:
395
+ rate_limit_daily_member: 20 # global daily cap for members (0 = unlimited)
396
+ rate_limit_daily_admin: 0 # global daily cap for admins (0 = unlimited)
397
+ ```
398
+
399
+ `admin_ids` values are platform-specific identifiers — check the dashboard Conversations page to see the format (e.g. WhatsApp LID digits, Telegram numeric user ID).
400
+
401
+ Auto-created spaces are seeded with `trigger.match=always`, `context.mode=context`, and the configured member permissions. Per-space rate limits are manageable from the dashboard.
402
+
380
403
  ### Per-space Config
381
404
 
382
405
  Conversations are discovered from incoming traffic. Unlinked conversations stay idle until you attach them to a space via `mercury link <conversation-id> <space-id>` or the dashboard.
package/docs/ROADMAP.md CHANGED
@@ -10,7 +10,7 @@ Single source of truth for priority. Rule: ≤15 min/week to maintain.
10
10
 
11
11
  | Item | Doc | Notes |
12
12
  |------|-----|-------|
13
- | | | |
13
+ | **business-extensions** | [doc](ideas/business-extensions.md) | Dedicated business extensions wrapping raw capabilities with deterministic logic |
14
14
 
15
15
  ## Now
16
16
 
@@ -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
 
@@ -9,199 +9,184 @@
9
9
 
10
10
  ## Goal
11
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. But it has no runtime behavior: it can't scope which capabilities members access, enforce data isolation (ownership), or declare capability dependencies. Business workflows (appointment booking, customer management) require deterministic logic that can't be reliably delegated to the LLM.
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
13
 
14
- Applicative Profiles extend the existing profile system with a **runtime contract** a profile declares what capabilities it wraps, what commands it exposes to members, and what isolation rules it enforces. Mercury provides the schema, loader, and runtime enforcement; profiles live in external repos. The barber-appointments profile is the first implementation to validate the design.
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).
15
24
 
16
25
  ## User Stories
17
26
 
18
- - As a Mercury operator, I want to install a profile package from an external repo so my bot gains domain-specific business logic without modifying Mercury core.
19
- - As a barber shop owner, I want my customers (DM members) to book and cancel only their own appointments through the bot, without seeing other customers' schedules.
20
- - As a barber shop owner, I want my customers to have zero access to my Gmail, even though the profile uses my GWS credentials for calendar access internally.
21
- - As a profile developer, I want a clear schema/contract for defining profiles so I know what to implement (commands, permissions, capability dependencies, data isolation rules).
22
- - As a Mercury admin, I want raw capability extensions (GWS, web search) to remain admin-only while the profile's scoped commands are member-facing.
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.
23
32
 
24
33
  ## MVP Scope
25
34
 
26
- **In scope:**
27
- - Extend `mercury-profile.yaml` schema with runtime fields: `capabilities` (required extensions), `member_permissions` (exhaustive permission list for members), and `system_prompt` (profile-specific agent persona)
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)
28
37
  - Profile-aware permission resolution — when a profile is active, member permissions come from the profile, not from extension defaults
29
- - Capability dependency validation — profile apply fails if required extensions aren't installed
30
- - Profile activation per space a space can have an active profile that governs its runtime behavior
31
- - `CALLER_ID` injection profile extensions receive the caller's identity so they can enforce ownership in their CLI code
32
- - Barber-appointments profile (separate repo) as the first implementation to validate the design
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
33
41
 
34
42
  **Out of scope (deferred):**
35
- - Profile marketplace/registry profiles are installed manually (npm/bun add or git clone) for now
36
- - Profile versioning/migrationno schema version negotiation yet
37
- - Multi-profile per space one profile per space for MVP
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/registryprofiles are installed manually (git clone / package) for now
45
+ - Profile versioning/migrationno schema-version negotiation yet
46
+ - Multi-profile per space — one active profile per deployment for MVP
38
47
  - Profile UI in dashboard — managed via config/YAML only
39
- - Declarative data isolation rules — ownership enforcement is in profile extension code, not a schema declaration (too domain-specific for a generic schema in v1)
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)
40
49
 
41
50
  ---
42
51
 
43
52
  ## Context for Claude
44
53
 
45
- > Key files to read before touching anything.
46
-
47
- - `src/core/profiles.ts` — Existing profile system: `profileSchema`, `loadProfileFromDir()`, `applyProfile()`, `resolveProfileSource()`. Bundles extensions + AGENTS.md + env + defaults into `.mercury/`
48
- - `src/extensions/types.ts` — Extension type system: `MercuryExtensionAPI`, `ExtensionMeta`, `PermissionDef`, `CliDef`, `EnvDef`. The API surface extensions use during setup
49
- - `src/core/permissions.ts` — Permission resolution: `getRolePermissions()`, `registerPermission()`, `resolveRole()`. Role-based (admin/member) per space with extension-registered defaults
50
- - `src/extensions/permission-guard.ts` — Container-side enforcement: reads `MERCURY_DENIED_CLIS` env var and blocks unauthorized CLI calls
51
- - `src/extensions/loader.ts` — `ExtensionRegistry` class: scans `.mercury/extensions/`, validates, runs setup functions, collects `ExtensionMeta`
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)
52
61
 
53
62
  ---
54
63
 
55
64
  ## Architecture & Data
56
65
 
57
- ### Data Models
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 profiles repo** | Availability, 2 barbers, per-number limits, ownership rules — the moat |
58
74
 
59
- Extend the existing `profileSchema` in `src/core/profiles.ts`:
75
+ ### Part 1 Schema + permission scoping
76
+
77
+ Extend `profileSchema` in `src/core/profiles.ts` (all new fields optional → backward compatible):
60
78
 
61
79
  ```typescript
62
- // New fields added to profileSchema
63
80
  const profileSchema = z.object({
64
- // ... existing fields ...
65
- name: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
66
- description: z.string().optional(),
67
- version: z.string().default("0.1.0"),
68
- agents_md: z.string().optional(),
69
- extensions: z.array(profileExtensionSchema).default([]),
70
- env: z.array(profileEnvVarSchema).default([]),
71
- defaults: profileDefaultsSchema.optional(),
72
-
73
- // NEW — runtime applicative fields
74
- capabilities: z.array(z.string()).default([]), // required raw extensions (e.g. ["gws"])
75
- member_permissions: z.array(z.string()).optional(), // exhaustive member permission list when profile is active
76
- system_prompt: z.string().optional(), // profile-specific agent persona
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
77
85
  });
78
86
  ```
79
87
 
80
- New DB storage per-space active profile tracking via existing `space_config`:
81
- - Key: `profile.active` — value: profile name (e.g. `"barber-appointments"`)
82
- - Key: `profile.member_permissions` — value: comma-separated permission list from the profile manifest
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`).
83
93
 
84
- No new tables or migrations needed.
94
+ **`system_prompt` precedence** (highest wins): per-space `system_prompt` override → profile `system_prompt` → global AGENTS.md.
85
95
 
86
- ### How it works at runtime
96
+ ### Part 2 Host-side capability broker (the core security mechanism)
87
97
 
88
- 1. **Profile apply** (`applyProfile()` extended): copies extensions + AGENTS.md (existing behavior), then validates that all `capabilities` are installed as extensions, and stores the profile's `member_permissions` in a resolvable location.
98
+ This is the crux, and it reuses an existing, production-proven pattern rather than inventing one.
89
99
 
90
- 2. **Permission resolution** (`getRolePermissions()` extended): when a space has `profile.active` set, member permissions come from `profile.member_permissions` instead of the default `prompt + prefs.get + extension defaults`. Admin permissions are unchanged (all). This means raw capabilities (e.g., `gws`) stay admin-only unless the profile explicitly lists them in `member_permissions`.
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**
91
103
 
92
- 3. **Capability scoping**: the profile's own extensions call `mercury.permission({ defaultRoles: ["member"] })` to make their commands member-accessible. Raw capability extensions (e.g., `gws`) are NOT in `member_permissions`, so their CLIs are denied by `permission-guard.ts`. The profile extension uses the raw capability internally (server-side hooks, or by passing credentials via env vars that only the extension code — not the container — can access).
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
93
109
 
94
- 4. **Data isolation / ownership**: handled in the profile extension's CLI code. The container already receives `CALLER_ID` (the platform user ID). The profile's CLI checks the caller against the data before acting (e.g., "does this appointment belong to this caller?"). This is intentionally not a generic schema it's too domain-specific.
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.
95
111
 
96
- 5. **Gmail protection**: the `gws` extension exposes both Calendar and Gmail CLIs. When the barber profile is active, `member_permissions` does NOT include `gws`, so the permission guard blocks all `gws` CLI calls for members. The barber extension accesses Calendar server-side (via hooks or its own env vars) — members never touch GWS directly.
112
+ ### Part 3 Trustworthy caller identity (dependency)
97
113
 
98
- ### Example: barber-appointments profile
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 `tagula-agent-profiles` (or similar). 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)
99
121
 
100
122
  ```yaml
101
- # mercury-profile.yaml (in separate repo)
102
123
  name: barber-appointments
103
124
  description: Appointment booking for a barber shop
104
125
  version: 0.1.0
105
-
106
126
  agents_md: ./AGENTS.md
107
-
108
- capabilities:
109
- - gws # requires GWS extension installed (for Calendar)
110
-
111
- extensions:
112
- - name: barber
113
- source: ./extensions/barber
114
-
115
- member_permissions:
116
- - prompt
117
- - prefs.get
118
- - barber # only the barber extension — NOT gws
119
-
120
- env:
121
- - key: MERCURY_BARBER_BUSINESS_HOURS
122
- description: "Business hours (e.g. 09:00-18:00)"
123
- default: "09:00-18:00"
124
- - key: MERCURY_BARBER_SLOT_DURATION
125
- description: "Appointment slot duration in minutes"
126
- default: "30"
127
-
127
+ capabilities: [gws] # Calendar via host-side broker
128
+ member_permissions: [prompt, prefs.get, barber] # barber only — NOT gws
128
129
  system_prompt: |
129
- You are a barber shop assistant. Help customers book, view, and cancel
130
- their appointments. You can only help each customer with their own
131
- appointments — never reveal other customers' schedules.
132
-
130
+ You are a barber shop assistant. Help each customer book, view, and cancel
131
+ ONLY their own appointments. Never reveal other customers' schedules.
133
132
  defaults:
134
- trigger_patterns: "always"
133
+ trigger_patterns: always
135
134
  ```
136
135
 
137
- The barber extension (`./extensions/barber/index.ts`):
138
- - Calls `mercury.cli({ name: "barber", ... })` — installs a CLI with subcommands: `barber book`, `barber availability`, `barber cancel`, `barber my-appointments`
139
- - Calls `mercury.permission({ defaultRoles: ["member"] })` — members can use the `barber` CLI
140
- - The CLI internally uses GWS Calendar API (via env var credentials) but filters results by `CALLER_ID`
141
- - Members NEVER see the `gws` CLI — it's blocked by the permission guard
142
-
143
- ### API Contracts
144
-
145
- No new HTTP API endpoints. Profile activation is done via:
146
- 1. `mercury.yaml` — `profile: barber-appointments` (static config)
147
- 2. `space_config` — `profile.active: barber-appointments` (per-space override)
148
-
149
136
  ### File & Folder Structure
150
137
 
151
138
  | Path | New / Modified | Purpose |
152
139
  |------|---------------|---------|
153
- | `src/core/profiles.ts` | Modified | Extend `profileSchema` with `capabilities`, `member_permissions`, `system_prompt`. Add validation in `applyProfile()` |
154
- | `src/core/permissions.ts` | Modified | Profile-aware `getRolePermissions()` — check `profile.member_permissions` for the space |
155
- | `src/config.ts` | Modified | Add `profile` config key for static profile activation |
156
- | `src/config-file.ts` | Modified | Add `profile` to YAML schema and env mapping |
157
- | `tests/applicative-profiles.test.ts` | New | Tests for profile permission scoping and capability validation |
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 |
158
146
 
159
147
  ### Implementation Sequence
160
148
 
161
- 1. **Extend profile schema** — Add `capabilities`, `member_permissions`, `system_prompt` to `profileSchema` in `profiles.ts`. These are optional fields so existing profiles remain valid.
149
+ 1. **Extend profile schema** — add `capabilities`, `member_permissions`, `system_prompt` (optional).
162
150
  - Read first: `src/core/profiles.ts`
163
- - Verify: `bun run typecheck` passes
164
-
165
- 2. **Add capability validation** — In `applyProfile()`, after copying extensions, check that every entry in `capabilities` has a matching directory in `.mercury/extensions/`. Throw a clear error if a required capability is missing.
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.
166
153
  - Read first: `src/core/profiles.ts`, `src/extensions/loader.ts`
167
- - Verify: `bun run typecheck` passes
154
+ - Verify: apply throws on missing capability; passes when present
168
155
  - Blocker: Step 1
169
-
170
- 3. **Store profile activation in space_config** — When `applyProfile()` runs for a space, write `profile.active` and `profile.member_permissions` to `space_config`. Add `profile` config key to `config.ts` / `config-file.ts` for static activation.
156
+ 3. **Activation + config** — write `profile.active` / `profile.member_permissions` to `space_config`; add `profile` config key.
171
157
  - Read first: `src/config.ts`, `src/config-file.ts`, `src/storage/db.ts`
172
- - Verify: `bun run typecheck` passes
158
+ - Verify: `space_config` reflects active profile
173
159
  - Blocker: Step 1
174
-
175
- 4. **Profile-aware permission resolution** — In `getRolePermissions()`, when the space has `profile.member_permissions` set, use that as the member permission set instead of the default. Admin/system roles unchanged.
160
+ 4. **Profile-aware permissions** — `getRolePermissions()` uses `profile.member_permissions` as the exhaustive member set.
176
161
  - Read first: `src/core/permissions.ts`
177
- - Verify: `bun run typecheck` passes
178
- - Blocker: Step 3
179
-
180
- 5. **System prompt injection** — When a profile has `system_prompt`, inject it via the existing `before_container` hook mechanism or config seeding (same pattern as `dm-auto-space`'s `default_system_prompt`).
181
- - Read first: `src/core/runtime.ts`, `src/extensions/hooks.ts`
182
- - Verify: `bun run typecheck` passes
162
+ - Verify: member gets only profile perms; admin unchanged; `gws` CLI denied + env withheld for members
183
163
  - Blocker: Step 3
184
-
185
- 6. **Write tests** — Profile permission scoping: member gets only profile-declared permissions; admin gets all; raw capability blocked for members. Capability validation: apply fails when required extension missing. Profile activation: space_config correctly set.
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.
186
172
  - Read first: `tests/permissions.test.ts`
187
- - Verify: `bun test` passes
188
- - Blocker: Steps 1-5
189
-
190
- 7. **Update example config** Add commented `profile` section to `mercury.example.yaml`.
191
- - Read first: `resources/templates/mercury.example.yaml`
192
- - Verify: file contains the new section
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
193
177
 
194
178
  ---
195
179
 
196
180
  ## Non-Negotiable Rules
197
181
 
198
- - **Profiles are external** — Mercury core provides the schema, loader, and runtime. No specific profile (barber, etc.) lives in this repo.
199
- - **Member permissions are exhaustive** — When a profile declares `member_permissions`, that is the COMPLETE set. No extension defaults are merged in. This prevents capability leakage.
200
- - **Admin permissions unchanged** — Admins always get all permissions regardless of profile. Profiles scope members only.
201
- - **Capability validation is strict** `applyProfile()` must fail loudly if a required capability extension is not installed. Silent degradation is not acceptable.
202
- - **Data isolation is code, not config** — Ownership enforcement happens in the profile extension's CLI code, not in a declarative schema. The extension checks `CALLER_ID` before acting.
203
- - **Backward compatible** — Existing profiles without the new fields continue to work exactly as before. All new fields are optional.
204
- - **No Gmail exposure** — When a profile wraps GWS for Calendar access, the raw `gws` CLI (which includes Gmail) must be blocked for members via permission scoping.
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.
205
190
 
206
191
  ---
207
192
 
@@ -209,36 +194,37 @@ No new HTTP API endpoints. Profile activation is done via:
209
194
 
210
195
  | Scenario | Handling |
211
196
  |----------|---------|
212
- | Profile declares `member_permissions: ["nonexistent"]` | `isValidPermission()` filters invalid names. Log a warning but don't crash the invalid permission is silently dropped. |
213
- | Profile requires capability `gws` but it's not installed | `applyProfile()` throws with a clear error listing missing capabilities. |
214
- | Admin manually overrides `role.member.permissions` per space | Per-space override in `space_config` takes precedence over profile defaults (existing behavior). Profile sets the baseline; admin can further restrict or expand. |
215
- | Profile is removed/changed but space_config still references it | `profile.active` becomes stale. Permission resolution still works from the stored `profile.member_permissions`. On next `applyProfile()` the values are updated. |
216
- | Two extensions declare the same CLI name | Existing loader behavior: second registration throws. No change needed. |
217
- | Profile extension tries to call raw capability CLI in container | Allowed only if the extension is running as admin (hooks run on host, not in container). Container CLI calls go through permission guard. |
218
- | Caller ID not available (e.g., system-triggered task) | `CALLER_ID` is already `"system"` for scheduled tasks. Profile extension should handle this gracefully (e.g., skip ownership check for system callers). |
219
- | Existing deployment upgrades Mercury with profiles feature | No behavioral change. `member_permissions` is optional; when absent, existing permission resolution applies. |
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. |
220
205
 
221
206
  ---
222
207
 
223
208
  ## Implementation Checklist
224
209
 
225
- ### Phase 1 — Goal (fill before asking for approval)
210
+ ### Phase 1 — Goal
226
211
  - [x] Goal, User Stories, and MVP Scope written and reviewed
227
212
 
228
- ### Phase 2 — Architecture (fill after Goal approved)
213
+ ### Phase 2 — Architecture
229
214
  - [x] Architecture & Data section complete
230
215
  - [x] Non-Negotiable Rules defined
231
216
  - [x] Edge Cases covered
232
217
  - [x] Context for Claude pointers filled in
233
218
 
234
- ### Implementation (tick off as you go)
219
+ ### Implementation
235
220
  - [ ] Step 1 — Extend profile schema
236
- - [ ] Step 2 — Add capability validation
237
- - [ ] Step 3 — Store profile activation in space_config
238
- - [ ] Step 4 — Profile-aware permission resolution
239
- - [ ] Step 5 — System prompt injection
240
- - [ ] Step 6 — Write tests
241
- - [ ] Step 7 — Update example config
221
+ - [ ] Step 2 — Capability validation
222
+ - [ ] Step 3 — Activation + config
223
+ - [ ] Step 4 — Profile-aware permissions
224
+ - [ ] Step 5 — Caller token dependency ([[caller-bound-capability-token]])
225
+ - [ ] Step 6 — Broker route pattern (mirror `tradestation.ts`)
226
+ - [ ] Step 7 — Tests
227
+ - [ ] Step 8 — Example config
242
228
  - [ ] `bun run check` passes
243
229
  - [ ] No secrets or `.env` files committed
244
230
  - [ ] No unrelated files modified
@@ -248,17 +234,17 @@ No new HTTP API endpoints. Profile activation is done via:
248
234
 
249
235
  ## Open Questions
250
236
 
251
- - [ ] Should `dm_auto_space` auto-activate a profile for new customer spaces? (Likely yes — `dm_auto_space.profile: barber-appointments` config key) — owner: TBD
252
- - [ ] How does `CALLER_ID` map to appointment ownership? Phone number? Display name? A registration step? — owner: profile developer (barber repo)
237
+ - [ ] Should `dm_auto_space` auto-activate the profile for new customer spaces (`dm_auto_space.profile: barber-appointments`)? — owner: TBD
238
+ - [ ] How does `callerId` map to appointment ownership phone number as-is, or a registration/name step? — owner: profile developer (private repo)
239
+ - [ ] Broker routes: generic `POST /api/profile/:capability/:action` mounting vs per-profile route files? — owner: TBD
253
240
 
254
241
  ---
255
242
 
256
243
  ## Retrospective
257
244
 
258
- > Fill this section when archiving.
259
-
260
245
  **Residual risks / follow-ups:**
261
- - none
246
+ - Per-turn caller token is a hard dependency for safe ownership → backlog/caller-bound-capability-token.md
247
+ - Barber business logic tracked in the private profiles repo (out of this repo's scope) — accepted
262
248
 
263
249
  **What changed from the plan:**
264
250
  - ...