mercury-agent 0.16.2 → 0.17.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.
Files changed (62) hide show
  1. package/README.md +12 -0
  2. package/container/Dockerfile +1 -0
  3. package/container/Dockerfile.base +1 -0
  4. package/docs/autopilot/config.yaml +43 -0
  5. package/docs/behavior-layers.md +26 -12
  6. package/docs/container-lifecycle.md +1 -0
  7. package/docs/extensions.md +43 -0
  8. package/docs/goals/football-reporter-profile/decisions.md +37 -1
  9. package/docs/goals/football-reporter-profile/roadmap.md +63 -5
  10. package/docs/permissions.md +15 -0
  11. package/docs/profile-guide.md +304 -18
  12. package/docs/skills-guide.md +24 -0
  13. package/examples/extensions/feed-watch/config.ts +36 -0
  14. package/examples/extensions/feed-watch/digest.ts +230 -32
  15. package/examples/extensions/feed-watch/index.ts +8 -0
  16. package/examples/extensions/feed-watch/items.ts +48 -4
  17. package/examples/extensions/overview/actions.ts +264 -0
  18. package/examples/extensions/overview/gate.ts +82 -0
  19. package/examples/extensions/overview/index.ts +123 -0
  20. package/examples/extensions/overview/skill/SKILL.md +80 -0
  21. package/examples/profiles/_template/config.yaml +34 -0
  22. package/examples/profiles/football-reporter/AGENTS.md +24 -6
  23. package/examples/profiles/football-reporter/README.md +9 -1
  24. package/examples/profiles/football-reporter/config.yaml +31 -2
  25. package/examples/profiles/football-reporter/tasks/daily-article.md +6 -4
  26. package/package.json +7 -3
  27. package/resources/templates/mercury.example.yaml +5 -0
  28. package/src/adapters/whatsapp-media.ts +80 -3
  29. package/src/agent/container-entry.ts +143 -21
  30. package/src/agent/container-env.ts +11 -0
  31. package/src/agent/container-error.ts +12 -1
  32. package/src/agent/container-runner.ts +91 -9
  33. package/src/agent/pi-jsonl-parser.ts +166 -5
  34. package/src/bridges/discord.ts +4 -0
  35. package/src/bridges/slack.ts +4 -0
  36. package/src/bridges/teams.ts +4 -0
  37. package/src/bridges/telegram.ts +33 -1
  38. package/src/cli/build-image-tags.ts +34 -0
  39. package/src/cli/mercury.ts +51 -4
  40. package/src/config-file.ts +6 -0
  41. package/src/config.ts +28 -3
  42. package/src/core/attachment-notes.ts +4 -0
  43. package/src/core/confirmation.ts +3 -1
  44. package/src/core/handler.ts +2 -1
  45. package/src/core/media.ts +104 -2
  46. package/src/core/outbox.ts +23 -3
  47. package/src/core/reply-context.ts +61 -0
  48. package/src/core/routes/chat.ts +70 -3
  49. package/src/core/routes/config-builtin.ts +7 -0
  50. package/src/core/routes/dashboard.ts +91 -5
  51. package/src/core/runtime.ts +170 -22
  52. package/src/core/task-output.ts +36 -18
  53. package/src/core/task-scheduler.ts +18 -1
  54. package/src/extensions/catalog.ts +1 -0
  55. package/src/extensions/hooks.ts +8 -0
  56. package/src/extensions/loader.ts +68 -7
  57. package/src/extensions/types.ts +35 -2
  58. package/src/main.ts +19 -0
  59. package/src/server.ts +15 -8
  60. package/src/storage/db.ts +231 -12
  61. package/src/text/sanitize-text.ts +118 -0
  62. package/src/types.ts +73 -1
@@ -122,8 +122,27 @@ extension_config:
122
122
  # separately from reporting.
123
123
  feed-watch.quiet_hours: "00:30-07:30"
124
124
  feed-watch.timezone: "Asia/Jerusalem"
125
- feed-watch.max_per_hour: "3"
126
- feed-watch.batch_minutes: "4"
125
+ # Run rate. These two are the *cost* levers, and the cost law is
126
+ # `cost/day ≈ runs/day × prefix tokens × $2.50/M` — every run cache-writes its
127
+ # whole prefix, because runs are further apart than the 5-minute cache lives.
128
+ # At 3/hour and a 4-minute batch the space opened 46-48 runs/day, ~70 % of
129
+ # which answered NO_UPDATE. Lowered 2026-08-31 to 1/hour and a 15-minute
130
+ # batch: a wider batch also merges more outlets carrying one story into a
131
+ # single run, which is what the verify prompt asks the model to do anyway.
132
+ #
133
+ # These reduce the *number* of runs. They are not the digest fix — see
134
+ # `docs/debug/major/2026-08-31-feed-watch-digest-persisted-and-replayed.md` for the prefix size,
135
+ # which is the other factor in the same product.
136
+ feed-watch.max_per_hour: "1"
137
+ feed-watch.batch_minutes: "15"
138
+
139
+ # The other factor in the same product: how big each run's prompt is. The
140
+ # digest carries one window into the article, and on 2026-08-31 that was 346
141
+ # items / 130 KB. Capped here at 200 newest; the whole window, links included,
142
+ # is written to `knowledge/feed-digest-window.md` before the run, so nothing
143
+ # the cap holds back is out of reach. Declared explicitly rather than left to
144
+ # the extension default so `space-profile check` covers it.
145
+ feed-watch.digest_max_items: "200"
127
146
 
128
147
  # ── member permissions (M2.3, audit R7) ─────────────────────────────────
129
148
  #
@@ -139,6 +158,16 @@ extension_config:
139
158
  # above for why that one stays out of this file.
140
159
  role.member.permissions: "prompt,prefs.get,napkin,pdf,voice-transcribe,web-browser,tasks.create,tasks.list,tasks.pause,tasks.resume,tts.synthesize,media.receive,media.send"
141
160
 
161
+ # ── one voice (M2.4, audit R2, D-017) ──────────────────────────────────
162
+ #
163
+ # This space owns its standard: AGENTS.md here is the only persona the model
164
+ # hears. With this on, the host leaves the owner's global character ("Wiz, a
165
+ # personal assistant") out of the system prompt and does not mount the
166
+ # global AGENTS.md ("a helpful AI assistant… ask before acting") for runs in
167
+ # this space. Everything else — skills, models, the platform's security
168
+ # text — is unchanged. Profile-owned so a hand flip shows up as drift.
169
+ persona.exclusive: "true"
170
+
142
171
  # Stray items in the space workspace, deleted by apply and logged (D-009).
143
172
  # Paths are relative to the space directory and may not escape it.
144
173
  workspace_remove:
@@ -37,7 +37,8 @@
37
37
 
38
38
  ### בלוק הסיכום
39
39
 
40
- התשובה נפתחת בבלוק הזה, בלי שום תו לפניו:
40
+ התשובה נפתחת בבלוק הזה, בלי שום תו לפניו (כשההודעה עטופה בסמני המסירה
41
+ `[reply]` … `[/reply]`, הבלוק הוא השורה הראשונה **בתוך** הסמנים):
41
42
 
42
43
  ```
43
44
  [longview:summary]
@@ -79,7 +80,7 @@ DD.MM.YYYY · N מקורות
79
80
 
80
81
  *מקורות*
81
82
 
82
- - [<אתר>, <כותרת הידיעה>](<כתובת>), <תאריך>
83
+ - <אתר> — <כותרת הידיעה>, <תאריך>
83
84
  ```
84
85
 
85
86
  - **כותרת** — עובדתית וספציפית, עד 70 תווים, בלי סימני שאלה. היא גם כתובת
@@ -90,8 +91,9 @@ DD.MM.YYYY · N מקורות
90
91
  עם שם הדובר, ההקשר והתאריך. אם אין ציטוט כזה, אין את השורה הזאת.
91
92
  - **בוודאות / בספק** — שתי שורות ציטוט, כל אחת בשורה נפרדת עם שורה ריקה
92
93
  ביניהן. מה שלא רשמי נכנס ל"בספק" גם אם הוא סביר.
93
- - **מקורות** — רשימה של כל מה שקראת בפועל, עם קישור ותאריך. אתר שלא פתחת לא
94
- מופיע כאן.
94
+ - **מקורות** — רשימה של כל מה שקראת בפועל, בשם האתר ובתאריך. בלי כתובות URL:
95
+ הקישור משמש אותך לאימות לפני הכתיבה, לא את הקורא. אתר שלא פתחת לא מופיע כאן,
96
+ וזה לא משתנה — הרשימה היא הצהרה על מה שנקרא, לא קישוט.
95
97
 
96
98
  ### הבלוקים
97
99
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mercury-agent",
3
- "version": "0.16.2",
3
+ "version": "0.17.0",
4
4
  "description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
5
5
  "license": "MIT",
6
6
  "author": "Avishai Tsabari",
@@ -20,6 +20,10 @@
20
20
  },
21
21
  "type": "module",
22
22
  "exports": {
23
+ "./core/global-admin": {
24
+ "types": "./src/core/global-admin.ts",
25
+ "default": "./src/core/global-admin.ts"
26
+ },
23
27
  "./extensions/types": {
24
28
  "types": "./src/extensions/types.ts",
25
29
  "default": "./src/extensions/types.ts"
@@ -72,8 +76,8 @@
72
76
  "typecheck": "bunx tsc --noEmit",
73
77
  "typecheck:scripts": "bunx tsc --noEmit -p tsconfig.scripts.json",
74
78
  "typecheck:examples": "bunx tsc --noEmit -p tsconfig.examples.json",
75
- "lint": "bunx biome check src/ tests/ scripts/space-profile.ts examples/extensions/feed-watch/ examples/extensions/napkin/ examples/extensions/pinchtab/",
76
- "lint:fix": "bunx biome check --fix src/ tests/ scripts/space-profile.ts examples/extensions/feed-watch/ examples/extensions/napkin/ examples/extensions/pinchtab/",
79
+ "lint": "bunx biome check src/ tests/ scripts/space-profile.ts examples/extensions/feed-watch/ examples/extensions/napkin/ examples/extensions/pinchtab/ examples/extensions/overview/",
80
+ "lint:fix": "bunx biome check --fix src/ tests/ scripts/space-profile.ts examples/extensions/feed-watch/ examples/extensions/napkin/ examples/extensions/pinchtab/ examples/extensions/overview/",
77
81
  "check:silent-catch": "bun run scripts/check-silent-catch.ts",
78
82
  "check:sync-calls": "bun run scripts/check-sync-calls.ts",
79
83
  "check:dep-floors": "bun run scripts/check-dep-floors.ts",
@@ -62,6 +62,11 @@
62
62
  # image: ghcr.io/avishai-tsabari/mercury-agent:latest
63
63
  # container_timeout_ms: 300000
64
64
  # container_bwrap_docker_compat: false
65
+ # # Docker Desktop only. Mercury refuses the auto-detected sandbox relaxation
66
+ # # (seccomp=unconfined + SYS_ADMIN) unless you opt in here. Without it,
67
+ # # containers do not start on macOS/Windows. Ignored when
68
+ # # container_bwrap_docker_compat is set or the runtime is runsc.
69
+ # allow_unconfined: false
65
70
  # # Which MERCURY_* vars reach containers. "all" (default) passes every
66
71
  # # non-blocked var into every space's container; "claimed" passes only
67
72
  # # extension-declared vars, gated by the caller's permission.
@@ -15,8 +15,13 @@ import {
15
15
  type WAMessage,
16
16
  type WASocket,
17
17
  } from "@whiskeysockets/baileys";
18
- import { mimeToExt } from "../core/media.js";
18
+ import {
19
+ hasExecutableHeader,
20
+ isAllowedExtension,
21
+ mimeToExt,
22
+ } from "../core/media.js";
19
23
  import { logger } from "../logger.js";
24
+ import { stripInvisibleUnicode } from "../text/sanitize-text.js";
20
25
  import type {
21
26
  DroppedAttachment,
22
27
  MediaType,
@@ -170,6 +175,25 @@ export async function downloadWhatsAppMedia(
170
175
 
171
176
  const messageId = msg.key.id || `${Date.now()}`;
172
177
 
178
+ // Part A — extension allowlist, WARN-ONLY: logs what a rejection would have
179
+ // dropped, accepts the file regardless. Before the download, like
180
+ // `downloadMediaFromUrl`, so the log reflects what a rejection would have
181
+ // skipped fetching — a file that is oversized or fails to download would
182
+ // otherwise never be counted. Only meaningful when WhatsApp supplied a
183
+ // filename; without one the stored name is derived from the MIME type and
184
+ // there is no extension to judge.
185
+ if (mediaInfo.filename && !isAllowedExtension(mediaInfo.filename)) {
186
+ logger.warn(
187
+ "Attachment extension not in EXT_TO_MIME (warn-only, accepted)",
188
+ {
189
+ messageId,
190
+ filename: mediaInfo.filename,
191
+ mimeType: mediaInfo.mimeType,
192
+ type: mediaInfo.type,
193
+ },
194
+ );
195
+ }
196
+
173
197
  // Check file size before downloading
174
198
  if (mediaInfo.fileLength && mediaInfo.fileLength > options.maxSizeBytes) {
175
199
  logger.warn("Skipping large media file", {
@@ -222,6 +246,23 @@ export async function downloadWhatsAppMedia(
222
246
  return null;
223
247
  }
224
248
 
249
+ // Part B — binary-header denylist, enforced. Checked on the bytes already
250
+ // in memory, before anything reaches disk: never write-then-unlink.
251
+ if (hasExecutableHeader(buffer)) {
252
+ logger.warn("Dropping attachment with executable header", {
253
+ messageId,
254
+ filename: mediaInfo.filename,
255
+ mimeType: mediaInfo.mimeType,
256
+ type: mediaInfo.type,
257
+ sizeBytes: buffer.length,
258
+ });
259
+ options.onSkipped?.({
260
+ type: mediaInfo.type,
261
+ reason: "executable_content",
262
+ });
263
+ return null;
264
+ }
265
+
225
266
  // Ensure inbox directory exists
226
267
  const mediaDir = path.join(options.outputDir, "inbox");
227
268
  fs.mkdirSync(mediaDir, { recursive: true });
@@ -252,7 +293,7 @@ export async function downloadWhatsAppMedia(
252
293
  path: filePath,
253
294
  type: mediaInfo.type,
254
295
  mimeType: mediaInfo.mimeType,
255
- filename: mediaInfo.filename,
296
+ filename: stripInvisibleUnicode(mediaInfo.filename),
256
297
  sizeBytes: buffer.length,
257
298
  };
258
299
  } catch (error) {
@@ -304,6 +345,25 @@ export async function downloadQuotedMedia(
304
345
 
305
346
  const messageId = syntheticMsg.key.id || `${Date.now()}`;
306
347
 
348
+ // Part A — extension allowlist, WARN-ONLY: logs what a rejection would have
349
+ // dropped, accepts the file regardless. Before the download, like
350
+ // `downloadMediaFromUrl`, so the log reflects what a rejection would have
351
+ // skipped fetching — a file that is oversized or fails to download would
352
+ // otherwise never be counted. Only meaningful when WhatsApp supplied a
353
+ // filename; without one the stored name is derived from the MIME type and
354
+ // there is no extension to judge.
355
+ if (mediaInfo.filename && !isAllowedExtension(mediaInfo.filename)) {
356
+ logger.warn(
357
+ "Attachment extension not in EXT_TO_MIME (warn-only, accepted)",
358
+ {
359
+ messageId,
360
+ filename: mediaInfo.filename,
361
+ mimeType: mediaInfo.mimeType,
362
+ type: mediaInfo.type,
363
+ },
364
+ );
365
+ }
366
+
307
367
  if (mediaInfo.fileLength && mediaInfo.fileLength > options.maxSizeBytes) {
308
368
  logger.warn("Skipping large quoted media file", {
309
369
  messageId,
@@ -349,6 +409,23 @@ export async function downloadQuotedMedia(
349
409
  return null;
350
410
  }
351
411
 
412
+ // Same enforced gate as the primary download path. A quoted message is a
413
+ // second ingest leg into the same inbox, not a display-only reference.
414
+ if (hasExecutableHeader(buffer)) {
415
+ logger.warn("Dropping quoted attachment with executable header", {
416
+ messageId,
417
+ filename: mediaInfo.filename,
418
+ mimeType: mediaInfo.mimeType,
419
+ type: mediaInfo.type,
420
+ sizeBytes: buffer.length,
421
+ });
422
+ options.onSkipped?.({
423
+ type: mediaInfo.type,
424
+ reason: "executable_content",
425
+ });
426
+ return null;
427
+ }
428
+
352
429
  const mediaDir = path.join(options.outputDir, "inbox");
353
430
  fs.mkdirSync(mediaDir, { recursive: true });
354
431
 
@@ -375,7 +452,7 @@ export async function downloadQuotedMedia(
375
452
  path: filePath,
376
453
  type: mediaInfo.type,
377
454
  mimeType: mediaInfo.mimeType,
378
- filename: mediaInfo.filename,
455
+ filename: stripInvisibleUnicode(mediaInfo.filename),
379
456
  sizeBytes: buffer.length,
380
457
  };
381
458
  } catch (error) {
@@ -11,6 +11,7 @@ import {
11
11
  writeFileSync,
12
12
  } from "node:fs";
13
13
  import path from "node:path";
14
+ import { stripInvisibleUnicode } from "../text/sanitize-text.js";
14
15
  import type { MessageAttachment, StoredMessage } from "../types.js";
15
16
  import {
16
17
  type CapabilitySource,
@@ -95,6 +96,17 @@ export type Payload = {
95
96
  anchorMessages?: StoredMessage[];
96
97
  prompt: string;
97
98
  callerRole?: string;
99
+ /**
100
+ * The caller's effective permission set in this space (`permissions.ts`
101
+ * `getRolePermissions`). Platform-prompt sections that instruct the model to
102
+ * use a tool — "set a preference", "mute" — are emitted only when the caller
103
+ * holds the matching permission, so the model is never told to do a thing
104
+ * and then told to refuse it in the same turn.
105
+ *
106
+ * Optional for the same reason as the budget fields: an older host does not
107
+ * send it, and then every section is emitted exactly as before.
108
+ */
109
+ callerPermissions?: string[];
98
110
  authorName?: string;
99
111
  attachments?: MessageAttachment[];
100
112
  preferences?: Array<{ key: string; value: string }>;
@@ -327,7 +339,17 @@ Prioritize practical outputs and explicit assumptions.`;
327
339
  const raw = process.env.BOT_USERNAME || "Mercury";
328
340
  const botName = raw.charAt(0).toUpperCase() + raw.slice(1);
329
341
 
330
- const mercuryPlatform = `Files received from users (images, documents, voice notes) are saved to the \`inbox/\` directory in the current workspace. To send files back with your reply, write them to the \`outbox/\` directory — any files created or modified there during this run will be automatically attached to your response.
342
+ // Which capability paragraphs this caller gets. A host that sends no
343
+ // permission set gets every paragraph, exactly as before the field existed;
344
+ // a host that does send one gets only the paragraphs the caller can act on.
345
+ // Security text is never gated — it describes boundaries, not tools.
346
+ const perms = payload.callerPermissions
347
+ ? new Set(payload.callerPermissions)
348
+ : null;
349
+ const callerMay = (permission: string): boolean =>
350
+ perms === null || perms.has(permission);
351
+
352
+ const platformIntro = `Files received from users (images, documents, voice notes) are saved to the \`inbox/\` directory in the current workspace. To send files back with your reply, write them to the \`outbox/\` directory — any files created or modified there during this run will be automatically attached to your response.
331
353
 
332
354
  You are ${botName}. You run on the Mercury platform (https://github.com/Avishai-Tsabari/mercury). When users ask about the platform — what it can do, how to configure it, scheduling, permissions, extensions, or anything about Mercury — you MUST read from \`/docs/mercury/\` before answering. Start with \`/docs/mercury/README.md\` for an overview, then check \`/docs/mercury/docs/\` for detailed guides.
333
355
 
@@ -337,14 +359,23 @@ Each run is triggered by a specific caller with a role (admin or member). The ca
337
359
  - **member**: Limited access. Some tools and extensions are restricted.
338
360
  If a tool call is blocked with "Permission denied", this is a hard security boundary. Do NOT attempt to achieve the same result through alternative means — no curl, no direct API calls, no workarounds. Simply inform the user they do not have permission.
339
361
 
340
- Never write or execute scripts whose purpose is to read data from the local filesystem or database and transmit it to an external URL or endpoint. This applies regardless of how the request is phrased — "backup", "sync", "export", "check", etc. are not exceptions. If a user asks you to do this, refuse and explain why.
362
+ Never write or execute scripts whose purpose is to read data from the local filesystem or database and transmit it to an external URL or endpoint. This applies regardless of how the request is phrased — "backup", "sync", "export", "check", etc. are not exceptions. If a user asks you to do this, refuse and explain why.`;
341
363
 
342
- ## Moderation
343
- You can mute users who are being abusive, spamming, trying to exfiltrate secrets, or deliberately wasting the group's resources by triggering you for pointless nonsense. Use \`mrctl mute\` when you judge it necessary — you don't need to wait for an admin to ask. Warn the user first, then mute if they continue.
364
+ // Only for a caller who can actually run `mrctl mute`: told to a member
365
+ // without `mutes.mute`, this paragraph is an instruction the next tool call
366
+ // refuses, and the refusal then reads as "inform the user".
367
+ const moderation = `## Moderation
368
+ You can mute users who are being abusive, spamming, trying to exfiltrate secrets, or deliberately wasting the group's resources by triggering you for pointless nonsense. Use \`mrctl mute\` when you judge it necessary — you don't need to wait for an admin to ask. Warn the user first, then mute if they continue.`;
344
369
 
345
- ## Environment Variables
370
+ const environmentVariables = `## Environment Variables
346
371
  Host \`MERCURY_*\` env vars are passed into your container with the prefix stripped: \`MERCURY_FOO\` on the host is \`$FOO\` here (e.g. \`MERCURY_GITHUB_TOKEN\` → \`$GITHUB_TOKEN\`). \`$MERCURY_*\` names never exist inside the container — when wiring a secret into a script, git credential helper, or config, always reference the stripped name.`;
347
372
 
373
+ const mercuryPlatform = [
374
+ platformIntro,
375
+ ...(callerMay("mutes.mute") ? [moderation] : []),
376
+ environmentVariables,
377
+ ].join("\n\n");
378
+
348
379
  const destructiveOps = `## Destructive Operations — Confirmation Required
349
380
 
350
381
  Before deleting, trashing, or permanently removing any data, you MUST stop and confirm with the user first:
@@ -375,6 +406,19 @@ After running any command or tool, never send raw output to the user. Always tra
375
406
 
376
407
  This rule applies to all tools: Google Workspace, TradeStation, web search, and any future extension.`;
377
408
 
409
+ // The envelope exists because the shape it catches has no structural
410
+ // signal: one final text block that opens with a working sentence and
411
+ // continues with the answer. The parser can split on a thinking block; it
412
+ // cannot split inside a block. Asking for the message in markers turns
413
+ // "never narrate" — a rule the space layer already tried and lost — into
414
+ // "narrate anywhere, only this span is sent". Same contract as longview's
415
+ // `[longview:summary]`, honoured by `extractReplyEnvelope` in the parser.
416
+ const delivery = `## Delivery
417
+
418
+ Wrap the message you want delivered between a \`[reply]\` line and a \`[/reply]\` line, each marker alone on its own line. Text outside the markers is not sent — that is where a note on what you checked, a remark that a file was written, or a sentence after a tool result belongs. Put the markers on your final message only, once; if you have already written the message and then run one more tool, write it again inside the markers when you are done.
419
+
420
+ Everything a space's standard says about the message — language, length, format, the exact word to send when there is nothing to say — applies to the text inside the markers. Files still go to \`outbox/\`; the markers cover text only.`;
421
+
378
422
  const characterChangeFlow = `## Character
379
423
 
380
424
  The system prompt may include a "Bot Character" section — the owner-defined voice for
@@ -450,7 +494,15 @@ Your prompt carries a \`<run_budget>\` block with the exact kill time, the total
450
494
  }
451
495
  parts.push(destructiveOps);
452
496
  parts.push(toolResultPresentation);
453
- parts.push(characterChangeFlow);
497
+ parts.push(delivery);
498
+ // The character / preference flow is a set of `mrctl prefs set` and
499
+ // `mrctl character set` instructions. A caller without `prefs.set` cannot
500
+ // follow them, and the paragraph's "a standing instruction → set a
501
+ // preference" is exactly how a member's joke got escalated to "the admin
502
+ // must approve". Left out for that caller; the security text above stays.
503
+ if (callerMay("prefs.set")) {
504
+ parts.push(characterChangeFlow);
505
+ }
454
506
  if (payload.anchorMessages && payload.anchorMessages.length > 0) {
455
507
  parts.push(
456
508
  `When a \`<reply_anchor>\` block appears in the user prompt, the user is swipe-replying to those specific messages. Address the anchor content directly.`,
@@ -513,27 +565,44 @@ Current working directory: ${cwd}`;
513
565
  /**
514
566
  * Format attachment information for the prompt as XML.
515
567
  * Converts absolute paths to container-relative paths.
568
+ *
569
+ * This is the single point where an attachment's `filename` — a string the
570
+ * sender chose — becomes prompt text, so it is where the invisible-Unicode
571
+ * strip belongs. The seven ingest paths (five bridges, the web chat route and
572
+ * the shared URL downloader) all sanitize on the way in as well, but only this
573
+ * function is guaranteed to run for every attachment of every future bridge.
516
574
  */
517
- function formatAttachments(
575
+ export function formatAttachments(
518
576
  attachments: MessageAttachment[] | undefined,
519
577
  ): string | null {
520
578
  if (!attachments || attachments.length === 0) return null;
521
579
 
580
+ // Every remote-supplied value goes through this, not just the obvious one.
581
+ // Stripped because invisible carriers tokenize as instructions, and escaped
582
+ // because the value lands inside a double-quoted XML attribute where a bare
583
+ // `"` would close it early and let the remainder parse as markup of its own.
584
+ const attr = (value: string) =>
585
+ stripInvisibleUnicode(value).replace(/"/g, "&quot;");
586
+
522
587
  const entries = attachments.map((att) => {
523
588
  // Convert host path to container path
524
589
  const containerPath = att.path.replace(/^.*\/spaces\//, "/spaces/");
525
590
 
526
591
  const attrs = [
592
+ // `type` is a closed set from `mimeToMediaType` and `path` is
593
+ // ASCII-allowlisted at every ingest site, so both are already safe.
594
+ // `mimeType` is NOT: bridges pass the platform's `content_type` through
595
+ // verbatim, so it is exactly as attacker-controlled as `filename`.
527
596
  `type="${att.type}"`,
528
597
  `path="${containerPath}"`,
529
- `mime="${att.mimeType}"`,
598
+ `mime="${attr(att.mimeType)}"`,
530
599
  ];
531
600
 
532
601
  if (att.sizeBytes) {
533
602
  attrs.push(`size="${att.sizeBytes}"`);
534
603
  }
535
604
  if (att.filename) {
536
- attrs.push(`filename="${att.filename}"`);
605
+ attrs.push(`filename="${attr(att.filename)}"`);
537
606
  }
538
607
 
539
608
  return ` <attachment ${attrs.join(" ")} />`;
@@ -592,7 +661,21 @@ function parseEpisodeFrontmatter(content: string): EpisodeMeta | null {
592
661
  const titleMatch = content.match(/^#\s+(.+)$/m);
593
662
  const title = titleMatch ? titleMatch[1].trim() : "";
594
663
 
595
- const csMatch = content.match(/## Current State\n([\s\S]*?)(?=\n## |$)/);
664
+ // `[^\n]*` and not a bare `\n`: the distiller writes the heading as
665
+ // `## Current State (as of 2026-08-20)` whenever it feels like dating it, and
666
+ // an anchored `\n` then fails to match, yielding `currentState = ""`. The
667
+ // episode is still injected — as its one-line summary, with every fact gone
668
+ // and nothing logged. Ten of ten football notes were summary-only for a day
669
+ // before anyone noticed, because the block was present and looked alive.
670
+ //
671
+ // The live vault currently avoids this by carrying an HTML comment in each
672
+ // note telling the distiller to keep the heading bare. That is a workaround
673
+ // living in the data — one rewrite drops it and the failure returns silently.
674
+ // Accepting the suffix here is what actually closes it, and it cannot regress
675
+ // the bare form.
676
+ const csMatch = content.match(
677
+ /## Current State[^\n]*\n([\s\S]*?)(?=\n## |$)/,
678
+ );
596
679
  const currentState = csMatch ? csMatch[1].trim() : "";
597
680
 
598
681
  return {
@@ -761,8 +844,12 @@ function authorAttrs(m: StoredMessage): string {
761
844
  const HISTORY_CHAR_BUDGET = 400_000;
762
845
 
763
846
  export function buildHistoryXml(messages: StoredMessage[]): string | null {
764
- // Pair up user+assistant turns; skip ambient (they have their own section)
765
- const turns: Array<{ user: StoredMessage; assistant?: StoredMessage }> = [];
847
+ // Pair up user+assistant turns; skip ambient (they have their own section).
848
+ // An assistant row with no user row before it is a turn of its own, not
849
+ // noise: since the window stopped carrying scheduler prompts, every
850
+ // scheduled post arrives exactly like that, and dropping it would hide the
851
+ // bot's own published output from the model.
852
+ const turns: Array<{ user?: StoredMessage; assistant?: StoredMessage }> = [];
766
853
  let pendingUser: StoredMessage | null = null;
767
854
 
768
855
  for (const m of messages) {
@@ -771,8 +858,10 @@ export function buildHistoryXml(messages: StoredMessage[]): string | null {
771
858
  turns.push({ user: pendingUser });
772
859
  }
773
860
  pendingUser = m;
774
- } else if (m.role === "assistant" && pendingUser) {
775
- turns.push({ user: pendingUser, assistant: m });
861
+ } else if (m.role === "assistant") {
862
+ turns.push(
863
+ pendingUser ? { user: pendingUser, assistant: m } : { assistant: m },
864
+ );
776
865
  pendingUser = null;
777
866
  }
778
867
  }
@@ -788,10 +877,14 @@ export function buildHistoryXml(messages: StoredMessage[]): string | null {
788
877
  const turn = turns[i];
789
878
  if (!turn) break;
790
879
  const { user, assistant } = turn;
791
- const ts = formatContextTimestamp(user.createdAt);
792
- const userLine = ` <user${authorAttrs(user)}>${escapeXmlText(user.content)}</user>`;
880
+ const anchorRow = user ?? assistant;
881
+ if (!anchorRow) continue;
882
+ const ts = formatContextTimestamp(anchorRow.createdAt);
883
+ const userLine = user
884
+ ? ` <user${authorAttrs(user)}>${escapeXmlText(user.content)}</user>`
885
+ : "";
793
886
  const assistantLine = assistant
794
- ? `\n <assistant>${escapeXmlText(assistant.content)}</assistant>`
887
+ ? `${user ? "\n" : ""} <assistant>${escapeXmlText(assistant.content)}</assistant>`
795
888
  : "";
796
889
  const entry = ` <turn timestamp="${ts}">\n${userLine}${assistantLine}\n </turn>`;
797
890
 
@@ -805,12 +898,17 @@ export function buildHistoryXml(messages: StoredMessage[]): string | null {
805
898
  return `<history>\n${entries.join("\n")}\n</history>`;
806
899
  }
807
900
 
808
- function buildAnchorXml(messages: StoredMessage[]): string | null {
901
+ export function buildAnchorXml(messages: StoredMessage[]): string | null {
809
902
  if (!messages || messages.length === 0) return null;
810
903
 
904
+ // Author attributes for the same reason `<history>` carries them: the
905
+ // anchor is now the *only* rendering of the quoted message (the adapter's
906
+ // `<reply_to name=…>` copy is dropped when the anchor resolves), so a quoted
907
+ // member's name has to travel with the row.
811
908
  const entries = messages.map((m) => {
812
909
  const ts = formatContextTimestamp(m.createdAt);
813
- return ` <message role="${m.role}" timestamp="${ts}">${escapeXmlText(m.content)}</message>`;
910
+ const author = m.role === "assistant" ? "" : authorAttrs(m);
911
+ return ` <message role="${m.role}"${author} timestamp="${ts}">${escapeXmlText(m.content)}</message>`;
814
912
  });
815
913
 
816
914
  return `<reply_anchor>\n${entries.join("\n")}\n</reply_anchor>`;
@@ -902,7 +1000,11 @@ export function buildPrompt(payload: Payload): string {
902
1000
  const callerId = process.env.CALLER_ID ?? "unknown";
903
1001
  const role = payload.callerRole ?? "member";
904
1002
  const space = payload.spaceId ?? "unknown";
905
- const nameAttr = payload.authorName ? ` name="${payload.authorName}"` : "";
1003
+ // Display names are platform-supplied; a `"` in one would close the
1004
+ // attribute early, exactly as it would in `authorAttrs`.
1005
+ const nameAttr = payload.authorName
1006
+ ? ` name="${escapeXmlAttr(payload.authorName)}"`
1007
+ : "";
906
1008
  parts.push(
907
1009
  `<caller id="${callerId}"${nameAttr} role="${role}" space="${space}" />`,
908
1010
  );
@@ -992,7 +1094,19 @@ export function buildPrompt(payload: Payload): string {
992
1094
  // 8. Current prompt
993
1095
  parts.push(payload.prompt);
994
1096
 
995
- return parts.join("\n");
1097
+ // Stripped once, over the whole assembled prompt, rather than per section.
1098
+ // Every remote-supplied string lands here — the live turn, history bodies,
1099
+ // author and member display names, ambient and anchor messages,
1100
+ // preferences, member notes — and `escapeXmlText` handles only XML
1101
+ // metacharacters, not the Tags Block or bidi controls. One pass at the join
1102
+ // cannot miss a section (the attachment fix strips per site, and its sibling
1103
+ // report is exactly a section it did not reach), and it covers sections
1104
+ // added later without each having to remember. Host-authored scaffolding
1105
+ // never carries these characters, so the pass is a no-op on it. Emoji built
1106
+ // on ZWJ or Tags Block sequences (family, subdivision flags) degrade to
1107
+ // their components for the model — the trade the helper documents,
1108
+ // accepted here for the same reason.
1109
+ return stripInvisibleUnicode(parts.join("\n"));
996
1110
  }
997
1111
 
998
1112
  /** Read-only bwrap binds for each resource entry the host mounted into pi's agent dir. */
@@ -1255,6 +1369,14 @@ function invokePiOnce(
1255
1369
  blocks: parsed.droppedNarrationBlocks,
1256
1370
  });
1257
1371
  }
1372
+ if (parsed.narrationOutsideEnvelope) {
1373
+ // Same contract, the other shape: text the model wrote around its
1374
+ // `[reply]` envelope. A run that logs this is one that would have
1375
+ // leaked before the envelope existed.
1376
+ logTiming("container.pi.narration_outside_envelope", {
1377
+ chars: parsed.narrationOutsideEnvelope,
1378
+ });
1379
+ }
1258
1380
  if (parsed.piFailureMessage) {
1259
1381
  reject(new Error(parsed.piFailureMessage));
1260
1382
  return;
@@ -24,6 +24,9 @@ export const BLOCKED_ENV_VARS = new Set([
24
24
  "MERCURY_ADMINS",
25
25
  // Host-only: affects `docker run` flags, not the agent process inside the container
26
26
  "MERCURY_CONTAINER_BWRAP_DOCKER_COMPAT",
27
+ // Host-only for the same reason: it only decides whether the host applies the
28
+ // sandbox relaxation to `docker run`. Nothing inside reads it.
29
+ "MERCURY_CONTAINER_ALLOW_UNCONFINED",
27
30
  // Host-only: selects the OCI runtime for `docker run --runtime`; not meaningful inside the container
28
31
  "MERCURY_CONTAINER_RUNTIME",
29
32
  // Host-only: resolved volume mountpoint on the host; inner containers don't need it
@@ -37,6 +40,14 @@ export const BLOCKED_ENV_VARS = new Set([
37
40
  "MERCURY_TEAMS_APP_ID",
38
41
  "MERCURY_TEAMS_APP_PASSWORD",
39
42
  "MERCURY_WHATSAPP_AUTH_DIR",
43
+ // Belt to the overview extension's `mercury.env({ hostOnly: true })` brace.
44
+ // That declaration only protects the var while the extension is LOADED: an
45
+ // unclaimed MERCURY_* name falls through the blunt passthrough path, so an
46
+ // operator who sets this and then removes (or fails to load) the extension
47
+ // would ship the management space's id into every container as
48
+ // MANAGEMENT_SPACE_ID -- the one fact overview promises the agent never
49
+ // learns. Blocked here so the guarantee does not depend on load order.
50
+ "MERCURY_MANAGEMENT_SPACE_ID",
40
51
  ]);
41
52
 
42
53
  /**
@@ -3,7 +3,8 @@ export type ContainerFailureReason =
3
3
  | "oom"
4
4
  | "aborted"
5
5
  | "error"
6
- | "no-credentials";
6
+ | "no-credentials"
7
+ | "sandbox-unconfined";
7
8
 
8
9
  export class ContainerError extends Error {
9
10
  readonly reason: ContainerFailureReason;
@@ -52,6 +53,16 @@ export class ContainerError extends Error {
52
53
  );
53
54
  }
54
55
 
56
+ /**
57
+ * The host detected Docker Desktop and would have to strip seccomp/apparmor
58
+ * and grant SYS_ADMIN for bubblewrap to nest — a materially weaker sandbox
59
+ * than the same code gets on a plain Linux host. Refused unless the operator
60
+ * opted in, because a relaxation nobody chose is the one nobody knows about.
61
+ */
62
+ static sandboxUnconfined(detail: string): ContainerError {
63
+ return new ContainerError("sandbox-unconfined", null, detail);
64
+ }
65
+
55
66
  static error(exitCode: number, output: string): ContainerError {
56
67
  return new ContainerError(
57
68
  "error",