mercury-agent 0.18.2 → 0.19.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/docs/goals/rehearsal-bench/decisions.md +41 -3
- package/docs/goals/rehearsal-bench/roadmap.md +3 -1
- package/docs/goals/release-gate/decisions.md +40 -0
- package/docs/goals/release-gate/roadmap.md +35 -2
- package/examples/extensions/gws/index.ts +99 -8
- package/examples/extensions/morning/README.md +26 -15
- package/examples/extensions/morning/index.ts +27 -6
- package/examples/extensions/morning/lib/hosts.ts +16 -0
- package/examples/extensions/morning/lib/morning.ts +78 -0
- package/examples/extensions/morning/lib/upload.ts +584 -0
- package/examples/extensions/morning/skill/SKILL.md +34 -4
- package/package.json +7 -3
- package/src/core/runtime.ts +164 -2
package/src/core/runtime.ts
CHANGED
|
@@ -213,12 +213,36 @@ export class MercuryCoreRuntime {
|
|
|
213
213
|
* Returns a comma-joined display name of active sensitive connections,
|
|
214
214
|
* or null if none are active. Active means: credential env var set (for
|
|
215
215
|
* env-var-based connections) or any extension state stored (for OAuth connections).
|
|
216
|
+
*
|
|
217
|
+
* Scoped to a caller when `spaceId`/`callerRole` are supplied: a connection
|
|
218
|
+
* this caller cannot reach is not a connection this caller needs to confirm.
|
|
219
|
+
* Reachability mirrors the env-var injection rule in `executePrompt`
|
|
220
|
+
* (`ext.permission && !hasPermission(...)` → skip), and it is the same
|
|
221
|
+
* permission the capability broker enforces — `checkPerm(c, name)` in
|
|
222
|
+
* src/core/routes/capability.ts gates on the permission whose name equals
|
|
223
|
+
* the extension's. Without a caller (the startup warning) the scan stays
|
|
224
|
+
* host-wide, which over-approximates on purpose: a log line should name
|
|
225
|
+
* every space that *could* be affected.
|
|
216
226
|
*/
|
|
217
|
-
private getActiveSensitiveConnectionName(
|
|
227
|
+
private getActiveSensitiveConnectionName(
|
|
228
|
+
spaceId?: string,
|
|
229
|
+
callerRole?: string,
|
|
230
|
+
): string | null {
|
|
218
231
|
if (!this.extensionRegistry) return null;
|
|
219
232
|
const names: string[] = [];
|
|
220
233
|
for (const ext of this.extensionRegistry.list()) {
|
|
221
234
|
if (!ext.connection?.sensitive) continue;
|
|
235
|
+
// An extension that declares no permission is reachable by everyone, so
|
|
236
|
+
// absence of `permission` must NOT skip the guard — same polarity as the
|
|
237
|
+
// injection rule this mirrors.
|
|
238
|
+
if (
|
|
239
|
+
spaceId !== undefined &&
|
|
240
|
+
callerRole !== undefined &&
|
|
241
|
+
ext.permission &&
|
|
242
|
+
!hasPermission(this.db, spaceId, callerRole, ext.name)
|
|
243
|
+
) {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
222
246
|
const conn = ext.connection;
|
|
223
247
|
let active = false;
|
|
224
248
|
if (conn.credentialEnvVar) {
|
|
@@ -235,6 +259,21 @@ export class MercuryCoreRuntime {
|
|
|
235
259
|
private static readonly PENDING_SENSITIVE_KEY = "security.pending_sensitive";
|
|
236
260
|
private static readonly PENDING_SENSITIVE_TTL_MS = 5 * 60 * 1000;
|
|
237
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Confirmations that have already been given, keyed by caller — a separate
|
|
264
|
+
* row from PENDING_SENSITIVE_KEY on purpose. `readPendingSensitive` requires
|
|
265
|
+
* a `prompt` string and drops anything else, so an "already confirmed" marker
|
|
266
|
+
* cannot ride in that map without loosening a validator that is doing real
|
|
267
|
+
* work.
|
|
268
|
+
*
|
|
269
|
+
* The window is a judgement call, not a derived value: long enough that a
|
|
270
|
+
* working session is not interrogated on every turn (a container turn alone
|
|
271
|
+
* can run 20 minutes), short enough that consent expires within the day.
|
|
272
|
+
*/
|
|
273
|
+
private static readonly CONFIRMED_SENSITIVE_KEY =
|
|
274
|
+
"security.confirmed_sensitive";
|
|
275
|
+
private static readonly CONFIRMED_SENSITIVE_TTL_MS = 60 * 60 * 1000;
|
|
276
|
+
|
|
238
277
|
/**
|
|
239
278
|
* Sensitive connection guard — fires before the container for assistant turns.
|
|
240
279
|
* Returns { action: "proceed" } (proceed, optionally replaying a stored prompt)
|
|
@@ -264,7 +303,24 @@ export class MercuryCoreRuntime {
|
|
|
264
303
|
return { action: "proceed" };
|
|
265
304
|
}
|
|
266
305
|
|
|
267
|
-
|
|
306
|
+
// Resolve the caller's role so the scan below can skip connections this
|
|
307
|
+
// caller cannot reach. resolveRole is idempotent (it upserts the member row
|
|
308
|
+
// and re-seeds config admins); executePrompt resolves it again later for
|
|
309
|
+
// its own use, exactly as the media gate at :1797 already does.
|
|
310
|
+
const seededAdmins = this.config.admins
|
|
311
|
+
? this.config.admins
|
|
312
|
+
.split(",")
|
|
313
|
+
.map((s) => s.trim())
|
|
314
|
+
.filter(Boolean)
|
|
315
|
+
: [];
|
|
316
|
+
const callerRole = callerId
|
|
317
|
+
? resolveRole(this.db, spaceId, callerId, seededAdmins)
|
|
318
|
+
: undefined;
|
|
319
|
+
|
|
320
|
+
const sensitiveName = this.getActiveSensitiveConnectionName(
|
|
321
|
+
spaceId,
|
|
322
|
+
callerRole,
|
|
323
|
+
);
|
|
268
324
|
if (!sensitiveName) return { action: "proceed" };
|
|
269
325
|
|
|
270
326
|
const locale = resolveLocale(this.db, this.config, spaceId);
|
|
@@ -300,6 +356,16 @@ export class MercuryCoreRuntime {
|
|
|
300
356
|
}
|
|
301
357
|
|
|
302
358
|
const now = Date.now();
|
|
359
|
+
|
|
360
|
+
// Already confirmed inside the window: proceed without asking again. This
|
|
361
|
+
// is checked BEFORE the pending map so a caller who has consented is never
|
|
362
|
+
// re-interrogated — the defect that made the guard cost one round-trip per
|
|
363
|
+
// message, forever, because the pending entry is consumed on every `yes`.
|
|
364
|
+
// An expired or absent entry falls through to the prompt: absence denies.
|
|
365
|
+
if (this.hasConfirmedSensitive(spaceId, callerId, now)) {
|
|
366
|
+
return { action: "proceed" };
|
|
367
|
+
}
|
|
368
|
+
|
|
303
369
|
const pending = this.readPendingSensitive(spaceId, now);
|
|
304
370
|
// Own-key lookup only: a caller id such as `constructor` must not find an
|
|
305
371
|
// inherited function and walk past the warning with an undefined prompt.
|
|
@@ -313,6 +379,9 @@ export class MercuryCoreRuntime {
|
|
|
313
379
|
if (words.no.includes(text)) {
|
|
314
380
|
delete pending[callerId];
|
|
315
381
|
this.writePendingSensitive(spaceId, pending);
|
|
382
|
+
// An explicit refusal revokes any standing consent: saying no must not
|
|
383
|
+
// leave an earlier `yes` still waving turns through.
|
|
384
|
+
this.clearConfirmedSensitive(spaceId, callerId);
|
|
316
385
|
return {
|
|
317
386
|
action: "block",
|
|
318
387
|
reason: formatSystemMessage(locale, "sensitive_cancelled"),
|
|
@@ -321,6 +390,7 @@ export class MercuryCoreRuntime {
|
|
|
321
390
|
if (words.yes.includes(text)) {
|
|
322
391
|
delete pending[callerId];
|
|
323
392
|
this.writePendingSensitive(spaceId, pending);
|
|
393
|
+
this.recordConfirmedSensitive(spaceId, callerId, now);
|
|
324
394
|
return { action: "proceed", replayPrompt: mine.prompt };
|
|
325
395
|
}
|
|
326
396
|
// A new message from the same caller mid-confirmation replaces their
|
|
@@ -399,6 +469,98 @@ export class MercuryCoreRuntime {
|
|
|
399
469
|
);
|
|
400
470
|
}
|
|
401
471
|
|
|
472
|
+
/**
|
|
473
|
+
* The live (unexpired, well-formed) confirmations of a space, keyed by
|
|
474
|
+
* caller. Same null-prototype and validation discipline as the pending map:
|
|
475
|
+
* caller ids are untrusted strings, an unreadable row is treated as empty,
|
|
476
|
+
* and anything malformed or expired is simply absent — which denies.
|
|
477
|
+
*/
|
|
478
|
+
private readConfirmedSensitive(
|
|
479
|
+
spaceId: string,
|
|
480
|
+
now: number,
|
|
481
|
+
): Record<string, { at: number }> {
|
|
482
|
+
const out: Record<string, { at: number }> = Object.create(null);
|
|
483
|
+
const raw = this.db.getSpaceConfig(
|
|
484
|
+
spaceId,
|
|
485
|
+
MercuryCoreRuntime.CONFIRMED_SENSITIVE_KEY,
|
|
486
|
+
);
|
|
487
|
+
if (!raw) return out;
|
|
488
|
+
let parsed: unknown;
|
|
489
|
+
try {
|
|
490
|
+
parsed = JSON.parse(raw);
|
|
491
|
+
} catch (error) {
|
|
492
|
+
logger.warn("Sensitive guard: discarding unreadable confirmed state", {
|
|
493
|
+
spaceId,
|
|
494
|
+
error: error instanceof Error ? error.message : String(error),
|
|
495
|
+
});
|
|
496
|
+
return out;
|
|
497
|
+
}
|
|
498
|
+
if (!parsed || typeof parsed !== "object") return out;
|
|
499
|
+
for (const [id, entry] of Object.entries(
|
|
500
|
+
parsed as Record<string, unknown>,
|
|
501
|
+
)) {
|
|
502
|
+
if (!entry || typeof entry !== "object") continue;
|
|
503
|
+
const { at } = entry as { at?: unknown };
|
|
504
|
+
if (typeof at !== "number") continue;
|
|
505
|
+
if (now - at > MercuryCoreRuntime.CONFIRMED_SENSITIVE_TTL_MS) continue;
|
|
506
|
+
out[id] = { at };
|
|
507
|
+
}
|
|
508
|
+
return out;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private writeConfirmedSensitive(
|
|
512
|
+
spaceId: string,
|
|
513
|
+
confirmed: Record<string, { at: number }>,
|
|
514
|
+
): void {
|
|
515
|
+
if (Object.keys(confirmed).length === 0) {
|
|
516
|
+
this.db.deleteSpaceConfig(
|
|
517
|
+
spaceId,
|
|
518
|
+
MercuryCoreRuntime.CONFIRMED_SENSITIVE_KEY,
|
|
519
|
+
);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
this.db.setSpaceConfig(
|
|
523
|
+
spaceId,
|
|
524
|
+
MercuryCoreRuntime.CONFIRMED_SENSITIVE_KEY,
|
|
525
|
+
JSON.stringify(confirmed),
|
|
526
|
+
"system",
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** True when this caller confirmed in this space inside the TTL. */
|
|
531
|
+
private hasConfirmedSensitive(
|
|
532
|
+
spaceId: string,
|
|
533
|
+
callerId: string,
|
|
534
|
+
now: number,
|
|
535
|
+
): boolean {
|
|
536
|
+
if (!callerId) return false;
|
|
537
|
+
const confirmed = this.readConfirmedSensitive(spaceId, now);
|
|
538
|
+
// Own-key lookup only, for the same reason the pending map uses it: a
|
|
539
|
+
// caller id such as `constructor` must not find an inherited function.
|
|
540
|
+
return Object.hasOwn(confirmed, callerId);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
private recordConfirmedSensitive(
|
|
544
|
+
spaceId: string,
|
|
545
|
+
callerId: string,
|
|
546
|
+
now: number,
|
|
547
|
+
): void {
|
|
548
|
+
if (!callerId) return;
|
|
549
|
+
const confirmed = this.readConfirmedSensitive(spaceId, now);
|
|
550
|
+
confirmed[callerId] = { at: now };
|
|
551
|
+
this.writeConfirmedSensitive(spaceId, confirmed);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private clearConfirmedSensitive(spaceId: string, callerId: string): void {
|
|
555
|
+
if (!callerId) return;
|
|
556
|
+
// Always writes back, even when this caller had no live entry: the read
|
|
557
|
+
// drops expired ones, so this is also what sweeps a stale row instead of
|
|
558
|
+
// leaving it to sit until someone else confirms.
|
|
559
|
+
const confirmed = this.readConfirmedSensitive(spaceId, Date.now());
|
|
560
|
+
delete confirmed[callerId];
|
|
561
|
+
this.writeConfirmedSensitive(spaceId, confirmed);
|
|
562
|
+
}
|
|
563
|
+
|
|
402
564
|
/** Log a startup warning for group spaces with active sensitive connections but no admin enable. */
|
|
403
565
|
private warnUncoveredSensitiveSpaces(): void {
|
|
404
566
|
const sensitiveName = this.getActiveSensitiveConnectionName();
|