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.
@@ -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(): string | null {
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
- const sensitiveName = this.getActiveSensitiveConnectionName();
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();