instar 1.3.602 → 1.3.604

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 (83) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +108 -2
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/config/ConfigDefaults.d.ts.map +1 -1
  5. package/dist/config/ConfigDefaults.js +36 -0
  6. package/dist/config/ConfigDefaults.js.map +1 -1
  7. package/dist/core/CoherenceJournal.d.ts +1 -1
  8. package/dist/core/CoherenceJournal.d.ts.map +1 -1
  9. package/dist/core/CoherenceJournal.js +12 -2
  10. package/dist/core/CoherenceJournal.js.map +1 -1
  11. package/dist/core/CredentialLocationLedger.d.ts +70 -0
  12. package/dist/core/CredentialLocationLedger.d.ts.map +1 -1
  13. package/dist/core/CredentialLocationLedger.js +126 -0
  14. package/dist/core/CredentialLocationLedger.js.map +1 -1
  15. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  16. package/dist/core/PostUpdateMigrator.js +25 -0
  17. package/dist/core/PostUpdateMigrator.js.map +1 -1
  18. package/dist/core/ThreadlinePairingReplicatedStore.d.ts +285 -0
  19. package/dist/core/ThreadlinePairingReplicatedStore.d.ts.map +1 -0
  20. package/dist/core/ThreadlinePairingReplicatedStore.js +462 -0
  21. package/dist/core/ThreadlinePairingReplicatedStore.js.map +1 -0
  22. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  23. package/dist/core/devGatedFeatures.js +5 -0
  24. package/dist/core/devGatedFeatures.js.map +1 -1
  25. package/dist/core/types.d.ts +22 -0
  26. package/dist/core/types.d.ts.map +1 -1
  27. package/dist/core/types.js.map +1 -1
  28. package/dist/scaffold/templates.d.ts.map +1 -1
  29. package/dist/scaffold/templates.js +6 -0
  30. package/dist/scaffold/templates.js.map +1 -1
  31. package/dist/server/routes.d.ts.map +1 -1
  32. package/dist/server/routes.js +323 -0
  33. package/dist/server/routes.js.map +1 -1
  34. package/dist/threadline/AgentTrustManager.d.ts +201 -1
  35. package/dist/threadline/AgentTrustManager.d.ts.map +1 -1
  36. package/dist/threadline/AgentTrustManager.js +395 -2
  37. package/dist/threadline/AgentTrustManager.js.map +1 -1
  38. package/dist/threadline/CredentialShareGate.d.ts +66 -0
  39. package/dist/threadline/CredentialShareGate.d.ts.map +1 -0
  40. package/dist/threadline/CredentialShareGate.js +82 -0
  41. package/dist/threadline/CredentialShareGate.js.map +1 -0
  42. package/dist/threadline/InboundMessageGate.d.ts +46 -1
  43. package/dist/threadline/InboundMessageGate.d.ts.map +1 -1
  44. package/dist/threadline/InboundMessageGate.js +105 -0
  45. package/dist/threadline/InboundMessageGate.js.map +1 -1
  46. package/dist/threadline/PairVerifyReceipt.d.ts +69 -0
  47. package/dist/threadline/PairVerifyReceipt.d.ts.map +1 -0
  48. package/dist/threadline/PairVerifyReceipt.js +151 -0
  49. package/dist/threadline/PairVerifyReceipt.js.map +1 -0
  50. package/dist/threadline/PairingPendingStore.d.ts +55 -0
  51. package/dist/threadline/PairingPendingStore.d.ts.map +1 -0
  52. package/dist/threadline/PairingPendingStore.js +110 -0
  53. package/dist/threadline/PairingPendingStore.js.map +1 -0
  54. package/dist/threadline/ThreadlineBootstrap.d.ts +11 -0
  55. package/dist/threadline/ThreadlineBootstrap.d.ts.map +1 -1
  56. package/dist/threadline/ThreadlineBootstrap.js +5 -0
  57. package/dist/threadline/ThreadlineBootstrap.js.map +1 -1
  58. package/dist/threadline/ThreadlineCrypto.d.ts +28 -0
  59. package/dist/threadline/ThreadlineCrypto.d.ts.map +1 -1
  60. package/dist/threadline/ThreadlineCrypto.js +100 -0
  61. package/dist/threadline/ThreadlineCrypto.js.map +1 -1
  62. package/dist/threadline/ThreadlineEndpoints.d.ts +8 -0
  63. package/dist/threadline/ThreadlineEndpoints.d.ts.map +1 -1
  64. package/dist/threadline/ThreadlineEndpoints.js +5 -0
  65. package/dist/threadline/ThreadlineEndpoints.js.map +1 -1
  66. package/dist/threadline/ThreadlineMCPServer.d.ts +3 -1
  67. package/dist/threadline/ThreadlineMCPServer.d.ts.map +1 -1
  68. package/dist/threadline/ThreadlineMCPServer.js +107 -1
  69. package/dist/threadline/ThreadlineMCPServer.js.map +1 -1
  70. package/dist/threadline/client/ThreadlineClient.d.ts +10 -0
  71. package/dist/threadline/client/ThreadlineClient.d.ts.map +1 -1
  72. package/dist/threadline/client/ThreadlineClient.js +13 -0
  73. package/dist/threadline/client/ThreadlineClient.js.map +1 -1
  74. package/package.json +2 -1
  75. package/scripts/check-e2e-pairing.cjs +4 -1
  76. package/src/data/builtin-manifest.json +63 -63
  77. package/src/data/state-coherence-registry.json +26 -0
  78. package/src/scaffold/templates.ts +6 -0
  79. package/src/threadline/data/sas-wordlist-en.json +1 -0
  80. package/upgrades/1.3.603.md +39 -0
  81. package/upgrades/1.3.604.md +57 -0
  82. package/upgrades/side-effects/credential-identity-audit.md +183 -0
  83. package/upgrades/side-effects/secure-a2a-verified-pairing.md +116 -0
@@ -140,6 +140,7 @@ import { THREAD_ID_RE } from '../threadline/ThreadLog.js';
140
140
  import { readThreadHistoryUnion } from '../threadline/canonicalHistoryRead.js';
141
141
  import { computeSymmetryState, localThreadSync, honorPeerThreadSync } from '../threadline/threadSymmetry.js';
142
142
  import { resolveSingleNegotiatorConfig, readNegotiatorCounts } from '../threadline/NegotiatorLease.js';
143
+ import { evaluateOutboundCredentialShare } from '../threadline/CredentialShareGate.js';
143
144
  import { detectCommitmentClass, commitmentNudge } from '../threadline/ContentClassifier.js';
144
145
  import { DEFAULT_RELAY_URL } from '../threadline/constants.js';
145
146
  import { ThreadlineNicknames } from '../threadline/ThreadlineNicknames.js';
@@ -9178,6 +9179,257 @@ export function createRoutes(ctx) {
9178
9179
  : undefined;
9179
9180
  res.json(ctx.a2aDeliveryTracker.peerHealth(req.params.fp, { staleAfterMs }));
9180
9181
  });
9182
+ // ── Secure A2A Verified Pairing surfaces (§3.6) ────────────────────────
9183
+ // List + detail + the PIN-gated operator verify. Ships DARK behind
9184
+ // threadline.verifiedPairing.enabled (resolved via the developmentAgent gate
9185
+ // when omitted, devAgentGate.ts) — when off, every route 503s and legacy
9186
+ // behavior is byte-identical. SAS WORDS are shown ONLY on the detail route to a
9187
+ // PIN-authed operator request while the pairing is pending-verification (§3.9);
9188
+ // they are never on the list route and never to a bearer-only request. The
9189
+ // verify route additionally REQUIRES the dashboard PIN (FD7): the agent Bearer
9190
+ // token is structurally insufficient to confirm a pairing — checkMandatePin is
9191
+ // the exact mechanism /mandate/issue uses (sha256 + timingSafeEqual + per-IP cap).
9192
+ const verifiedPairingRouteEnabled = () => resolveDevAgentGate(ctx.config.threadline?.verifiedPairing?.enabled, ctx.config);
9193
+ // Build THIS machine's local pairings list (the shape the plain + pool reads share,
9194
+ // so a peer's plain reply merges in identically). No SAS words ever.
9195
+ const buildLocalPairings = () => {
9196
+ const trustManager = ctx.unifiedTrust?.trustManager;
9197
+ if (!trustManager)
9198
+ return [];
9199
+ return trustManager
9200
+ .listProfiles()
9201
+ .filter((p) => p.pairingState !== undefined)
9202
+ .map((p) => ({
9203
+ peerFp: p.fingerprint ?? null,
9204
+ peerName: p.agent,
9205
+ state: p.pairingState,
9206
+ verifiedAt: p.verifiedAt,
9207
+ trustSource: p.source,
9208
+ peerAcked: p.peerAcked,
9209
+ }));
9210
+ };
9211
+ // Pool-scope pairing aggregation (Secure A2A Verified Pairing §3.8 — "is my channel to
9212
+ // <peer> verified?" gives ONE consistent answer regardless of fronting machine). Mirrors
9213
+ // the proven jobsPoolMerge shape: short-TTL cache, known-offline-peer skip, per-peer
9214
+ // timeout → failed marker (never a 500), shared pool-cache when wired. Single-machine ⇒
9215
+ // just the local list tagged scope:'pool'. The replicated RESULT itself flows via the
9216
+ // stateSync transport; THIS read is the cross-machine VIEW.
9217
+ const pairingPoolCache = new Map();
9218
+ const PAIRING_POOL_CACHE_TTL_MS = 3_000;
9219
+ const PAIRING_PEER_TIMEOUT_MS = 5_000;
9220
+ async function pairingPoolMerge(localPairings) {
9221
+ const cached = pairingPoolCache.get('pairing');
9222
+ if (cached && Date.now() - cached.at < PAIRING_POOL_CACHE_TTL_MS) {
9223
+ return cached.payload;
9224
+ }
9225
+ const selfMachineId = ctx.meshSelfId ?? null;
9226
+ const selfNickname = selfMachineId
9227
+ ? ctx.machinePoolRegistry?.getCapacity(selfMachineId)?.nickname ?? null
9228
+ : null;
9229
+ const peers = ctx.resolvePeerUrls?.() ?? [];
9230
+ const failed = [];
9231
+ const remote = [];
9232
+ let poolStale = false;
9233
+ await Promise.all(peers.map(async (p) => {
9234
+ const cap = ctx.machinePoolRegistry?.getCapacity(p.machineId);
9235
+ if (cap && cap.online === false) {
9236
+ failed.push({ machineId: p.machineId, error: 'offline' });
9237
+ return;
9238
+ }
9239
+ try {
9240
+ const fetchPeer = async () => {
9241
+ const r = await fetch(`${p.url}/threadline/pairing`, {
9242
+ headers: { Authorization: `Bearer ${ctx.config.authToken}` },
9243
+ signal: AbortSignal.timeout(PAIRING_PEER_TIMEOUT_MS),
9244
+ });
9245
+ if (!r.ok)
9246
+ throw new Error(`HTTP ${r.status}`);
9247
+ return (await r.json());
9248
+ };
9249
+ let body;
9250
+ if (ctx.poolPollCache) {
9251
+ const res = await ctx.poolPollCache.fetchPeer(p.machineId, '/threadline/pairing', fetchPeer);
9252
+ body = res.body;
9253
+ if (res.stale)
9254
+ poolStale = true;
9255
+ }
9256
+ else {
9257
+ body = await fetchPeer();
9258
+ }
9259
+ const nickname = cap?.nickname ?? null;
9260
+ for (const pr of body.pairings ?? []) {
9261
+ remote.push({ ...pr, machineId: pr.machineId ?? p.machineId, machineNickname: pr.machineNickname ?? nickname ?? undefined, remote: true });
9262
+ }
9263
+ }
9264
+ catch (err) {
9265
+ failed.push({ machineId: p.machineId, error: err instanceof Error ? err.message : String(err) });
9266
+ }
9267
+ }));
9268
+ const localTagged = localPairings.map((pr) => ({
9269
+ ...pr,
9270
+ machineId: pr.machineId ?? selfMachineId ?? undefined,
9271
+ machineNickname: pr.machineNickname ?? selfNickname ?? undefined,
9272
+ }));
9273
+ const pairings = [...localTagged, ...remote];
9274
+ const mutualVerifiedCount = pairings.filter((pr) => pr.state === 'mutual-verified' || pr.state === 'identity-verified').length;
9275
+ const payload = {
9276
+ pairings,
9277
+ mutualVerifiedCount,
9278
+ scope: 'pool',
9279
+ pool: {
9280
+ enabled: !!ctx.machinePoolRegistry,
9281
+ selfMachineId,
9282
+ peersQueried: peers.length,
9283
+ peersOk: peers.length - failed.length,
9284
+ failed,
9285
+ ...(poolStale ? { stale: true } : {}),
9286
+ },
9287
+ };
9288
+ pairingPoolCache.set('pairing', { at: Date.now(), payload });
9289
+ return payload;
9290
+ }
9291
+ // List all pairings (read-only, no SAS words ever). 503 when the feature is off.
9292
+ // `?scope=pool` merges every online machine's pairings (dark-peer-tolerant) so the
9293
+ // "is this channel verified?" answer is consistent regardless of fronting machine (§3.8).
9294
+ router.get('/threadline/pairing', async (req, res) => {
9295
+ if (!verifiedPairingRouteEnabled()) {
9296
+ res.status(503).json({ error: 'verified pairing not enabled' });
9297
+ return;
9298
+ }
9299
+ const trustManager = ctx.unifiedTrust?.trustManager;
9300
+ if (!trustManager) {
9301
+ res.status(503).json({ error: 'trust manager unavailable' });
9302
+ return;
9303
+ }
9304
+ const pairings = buildLocalPairings();
9305
+ if (req.query.scope === 'pool') {
9306
+ res.json(await pairingPoolMerge(pairings));
9307
+ return;
9308
+ }
9309
+ res.json({ pairings });
9310
+ });
9311
+ // One pairing's detail. The SAS WORDS are included ONLY while the pairing is
9312
+ // pending-verification AND the request carries a valid dashboard-PIN operator
9313
+ // auth (§3.9). Without the PIN the words are omitted (the rest is returned).
9314
+ // The PIN arrives in the request body (mirrors checkMandatePin's body.pin); a
9315
+ // GET with a JSON body is accepted by the express.json() middleware. Detection
9316
+ // of "PIN present" is non-throwing — an absent/incorrect PIN simply omits words,
9317
+ // it does not 403 the read (the read itself is bearer-gated, like the list).
9318
+ router.get('/threadline/pairing/:peerFp', (req, res) => {
9319
+ if (!verifiedPairingRouteEnabled()) {
9320
+ res.status(503).json({ error: 'verified pairing not enabled' });
9321
+ return;
9322
+ }
9323
+ const trustManager = ctx.unifiedTrust?.trustManager;
9324
+ if (!trustManager) {
9325
+ res.status(503).json({ error: 'trust manager unavailable' });
9326
+ return;
9327
+ }
9328
+ const peerFp = req.params.peerFp;
9329
+ const profile = trustManager.getProfileByFingerprint(peerFp);
9330
+ if (!profile || profile.pairingState === undefined) {
9331
+ res.status(404).json({ error: `no pairing for fingerprint "${peerFp}"` });
9332
+ return;
9333
+ }
9334
+ const detail = {
9335
+ peerFp: profile.fingerprint ?? peerFp,
9336
+ peerName: profile.agent,
9337
+ state: profile.pairingState,
9338
+ verifiedAt: profile.verifiedAt,
9339
+ trustSource: profile.source,
9340
+ peerAcked: profile.peerAcked,
9341
+ pairingId: profile.pairingId,
9342
+ sasFingerprint: profile.sasFingerprint,
9343
+ };
9344
+ // SAS words: pending-verification ONLY, and ONLY to a valid-PIN operator.
9345
+ // We validate the PIN WITHOUT sending a response (the detail read succeeds
9346
+ // regardless) — a non-throwing constant-time compare mirroring checkMandatePin.
9347
+ const pin = (req.body ?? {}).pin;
9348
+ const pinValid = !!ctx.config.dashboardPin &&
9349
+ typeof pin === 'string' &&
9350
+ pin.length > 0 &&
9351
+ (() => {
9352
+ const ha = createHash('sha256').update(pin).digest();
9353
+ const hb = createHash('sha256').update(ctx.config.dashboardPin).digest();
9354
+ return ha.length === hb.length && timingSafeEqual(ha, hb);
9355
+ })();
9356
+ if (profile.pairingState === 'pending-verification' && pinValid) {
9357
+ const pending = trustManager.getPendingPairing(peerFp);
9358
+ if (pending)
9359
+ detail.sasWords = pending.sasWords;
9360
+ }
9361
+ res.json({ pairing: detail });
9362
+ });
9363
+ // Operator confirm/deny of a pairing — PIN-GATED (FD7). The local human SAS
9364
+ // comparison is the load-bearing security event; the agent Bearer token CANNOT
9365
+ // confirm. checkMandatePin is the EXACT mechanism /mandate/issue uses.
9366
+ router.post('/threadline/pairing/:peerFp/verify', (req, res) => {
9367
+ if (!verifiedPairingRouteEnabled()) {
9368
+ res.status(503).json({ error: 'verified pairing not enabled' });
9369
+ return;
9370
+ }
9371
+ if (!checkMandatePin(req, res))
9372
+ return; // FD7 — PIN required (response already sent)
9373
+ const trustManager = ctx.unifiedTrust?.trustManager;
9374
+ if (!trustManager) {
9375
+ res.status(503).json({ error: 'trust manager unavailable' });
9376
+ return;
9377
+ }
9378
+ const peerFp = req.params.peerFp;
9379
+ const body = req.body ?? {};
9380
+ if (typeof body.match !== 'boolean') {
9381
+ res.status(400).json({ error: 'match (boolean) is required' });
9382
+ return;
9383
+ }
9384
+ const profile = trustManager.getProfileByFingerprint(peerFp);
9385
+ if (!profile || profile.pairingState === undefined) {
9386
+ res.status(404).json({ error: `no pairing for fingerprint "${peerFp}"` });
9387
+ return;
9388
+ }
9389
+ if (body.match === false) {
9390
+ const ok = trustManager.markVerificationFailed(peerFp, 'Operator-asserted SAS mismatch (POST /threadline/pairing/:peerFp/verify)');
9391
+ if (!ok) {
9392
+ res.status(409).json({ error: 'could not mark verification-failed for this pairing' });
9393
+ return;
9394
+ }
9395
+ // §3.2 / §3.7 — an operator-asserted SAS mismatch is a potential MITM and must
9396
+ // raise ONE HIGH attention item, deduped per (peerFp) episode, NEVER coalesced
9397
+ // (a re-attempt LOOP — repeated handshake-then-mismatch — collapses onto the SAME
9398
+ // item by its stable per-peerFp id rather than spawning one topic per attempt).
9399
+ // HIGH priority is itself never coalesced by the AttentionTopicGuard, so the
9400
+ // flood-guard chokepoint enforces the burst invariant for free. Best-effort —
9401
+ // a notification failure never breaks the local trust write (which already landed).
9402
+ void ctx.telegram?.createAttentionItem?.({
9403
+ id: `threadline-pairing-verification-failed:${peerFp}`,
9404
+ title: 'Threadline pairing verification FAILED — possible MITM',
9405
+ summary: `The SAS for peer ${peerFp.slice(0, 12)}… was asserted NOT to match. The peer was forced to untrusted and credential-share revoked. Re-handshake before retrying.`,
9406
+ category: 'general',
9407
+ priority: 'HIGH',
9408
+ sourceContext: 'threadline-verified-pairing',
9409
+ });
9410
+ res.json({ ok: true, peerFp, state: 'verification-failed' });
9411
+ return;
9412
+ }
9413
+ // match === true → confirm. Bind to the CURRENT pairingId from the pending
9414
+ // record (epoch binding, FD4). markMutualVerified is the SOLE writer of the
9415
+ // mutual-verified source (§3.3) and re-verifies the operator-confirm precondition.
9416
+ const pending = trustManager.getPendingPairing(peerFp);
9417
+ const pairingId = pending?.pairingId ?? profile.pairingId;
9418
+ if (!pairingId) {
9419
+ res.status(409).json({ error: 'no current pairingId for this pairing — re-handshake required' });
9420
+ return;
9421
+ }
9422
+ const ok = trustManager.markMutualVerified(peerFp, {
9423
+ pairingId,
9424
+ operatorConfirm: true,
9425
+ peerAcked: profile.peerAcked,
9426
+ });
9427
+ if (!ok) {
9428
+ res.status(409).json({ error: 'could not confirm pairing (state/epoch mismatch or not pending) — re-handshake required' });
9429
+ return;
9430
+ }
9431
+ res.json({ ok: true, peerFp, state: 'mutual-verified' });
9432
+ });
9181
9433
  // ── Negotiator lease state (Robustness Phase 1, FD-9) ──────────────────
9182
9434
  // "Which session owns each conversation's voice?" — bearer-gated, paginated,
9183
9435
  // own-data-only. Returns this agent's lease per active conversation (holder +
@@ -18877,6 +19129,10 @@ export function createRoutes(ctx) {
18877
19129
  // B3b: the balancer is now WIRED — surface its last-pass + breaker status.
18878
19130
  balancerWired: !!cr.rebalancer,
18879
19131
  rebalancer: cr.rebalancer ? cr.rebalancer.status() : null,
19132
+ // B3c: the NON-DESTRUCTIVE identity audit's last pass — the freshness-keeper that prevents
19133
+ // the rebalancer from decaying to inert (every objective needs verified-recent targets).
19134
+ // null until the first audit runs (~90s after boot). Counts only; no credential material.
19135
+ identityAudit: cr.ledger.getLastAuditReport(),
18880
19136
  envTokenGate: {
18881
19137
  refused: verdict.refused,
18882
19138
  // A named CATEGORY, not a credential; scrubbed regardless by credSend → audit.response.
@@ -19989,6 +20245,19 @@ export function createRoutes(ctx) {
19989
20245
  localAgent: ctx.config.projectName,
19990
20246
  version: '1.0',
19991
20247
  stateDir: ctx.config.stateDir,
20248
+ // Secure A2A Verified Pairing (§3.6): surface mutualVerifiedCount on
20249
+ // /threadline/health. The count is the number of profiles whose pairing
20250
+ // reached 'mutual-verified'. Gated on the verified-pairing flag so a dark
20251
+ // agent reports nothing new (legacy health shape preserved).
20252
+ mutualVerifiedCount: () => {
20253
+ const enabled = resolveDevAgentGate(ctx.config.threadline?.verifiedPairing?.enabled, ctx.config);
20254
+ if (!enabled)
20255
+ return 0;
20256
+ const tm = ctx.unifiedTrust?.trustManager;
20257
+ if (!tm)
20258
+ return 0;
20259
+ return tm.listProfiles().filter((p) => p.pairingState === 'mutual-verified').length;
20260
+ },
19992
20261
  },
19993
20262
  // Robustness Phase 1 (D-E / F4): wire the ack funnel so the verified E2E
19994
20263
  // relay inbound path records the implicit ack (the one path that lacked it).
@@ -20175,6 +20444,46 @@ export function createRoutes(ctx) {
20175
20444
  res.status(400).json({ success: false, error: 'Missing required fields: targetAgent, message' });
20176
20445
  return;
20177
20446
  }
20447
+ // ── Outbound credential-share intent (Secure A2A Verified Pairing §3.5) ──
20448
+ // A credential-bearing send is signalled by the caller (kind:'credential-share'
20449
+ // or credentialShare:true). This is a caller HINT for the OUTBOUND path only — the
20450
+ // SECURITY decision is keyed on WHO the resolved recipient is (mutual-verified),
20451
+ // never the label (FD5). When set, the funnel REFUSES the send unless the recipient
20452
+ // is mutual-verified AND the encrypted+signed path is available (never plaintext).
20453
+ // LIVE whenever verified-pairing is enabled — NOT gated by dryRun (FD10).
20454
+ const isCredentialShareSend = req.body?.kind === 'credential-share' || req.body?.credentialShare === true;
20455
+ const verifiedPairingEnabled = resolveDevAgentGate(ctx.config.threadline?.verifiedPairing?.enabled, ctx.config);
20456
+ /**
20457
+ * Outbound credential-share chokepoint (load-bearing, §3.5). Returns true if the
20458
+ * caller is sending a credential to a NON-eligible recipient and the response has
20459
+ * been written (the route must `return`). When verified-pairing is disabled, or the
20460
+ * send is not credential-bearing, this is a no-op (returns false). Fail-closed (FD9):
20461
+ * a missing trust manager / relay client → refuse a credential-bearing send.
20462
+ *
20463
+ * @param recipientFp the RESOLVED full routing fingerprint (never a name).
20464
+ */
20465
+ const refuseCredentialShareIfBlocked = (recipientFp) => {
20466
+ if (!isCredentialShareSend || !verifiedPairingEnabled)
20467
+ return false;
20468
+ const trustManager = ctx.unifiedTrust?.trustManager;
20469
+ if (!trustManager) {
20470
+ // FD9 — cannot resolve pairing state ⇒ fail closed for a credential.
20471
+ res.status(403).json({
20472
+ success: false,
20473
+ refused: true,
20474
+ reason: 'peer-not-mutually-verified',
20475
+ error: 'Credential-share refused: pairing state cannot be resolved.',
20476
+ });
20477
+ return true;
20478
+ }
20479
+ const decision = evaluateOutboundCredentialShare(trustManager, ctx.threadlineRelayClient ?? null, recipientFp);
20480
+ if (!decision.allow) {
20481
+ console.warn(`[relay-send] credential-share REFUSED to=${recipientFp.slice(0, 12)} reason=${decision.reason}`);
20482
+ res.status(403).json({ success: false, refused: true, reason: decision.reason, error: `Credential-share refused: ${decision.reason}.` });
20483
+ return true;
20484
+ }
20485
+ return false;
20486
+ };
20178
20487
  // Caller-supplied priority — accept ['critical','high','medium','low'].
20179
20488
  // Default to 'medium' when omitted. Reject any unknown string so caller
20180
20489
  // gets a clear 400 rather than silently downgraded delivery. The local
@@ -20552,6 +20861,12 @@ export function createRoutes(ctx) {
20552
20861
  catch { /* @silent-fallback-ok — identity.json read is best-effort */ }
20553
20862
  }
20554
20863
  if (localTarget?.port && !isSelfTarget) {
20864
+ // §3.5 outbound credential-share chokepoint (local-delivery path). A credential
20865
+ // must go to a mutual-verified peer over the encrypted+signed path only — the
20866
+ // plaintext localhost relay-agent path is refused for a credential.
20867
+ const localFp = resolvePeerFingerprint(localTarget) ?? '';
20868
+ if (refuseCredentialShareIfBlocked(localFp))
20869
+ return;
20555
20870
  // Check if the local agent is actually running
20556
20871
  try {
20557
20872
  const healthResp = await fetch(`http://localhost:${localTarget.port}/threadline/health`, {
@@ -20794,6 +21109,14 @@ export function createRoutes(ctx) {
20794
21109
  });
20795
21110
  return;
20796
21111
  }
21112
+ // §3.5 outbound credential-share chokepoint (relay-delivery path). Refuse a
21113
+ // credential unless the resolved peer is mutual-verified AND the encrypted+signed
21114
+ // path is available — `sendAuto` would otherwise silently use the plaintext path
21115
+ // for an unknown-key peer, which a credential must never traverse.
21116
+ // E2E-PAIRING: EXEMPT — comment reword only, no route behavior change; the pairing
21117
+ // routes already have E2E coverage in tests/e2e/threadline-verified-pairing-alive.test.ts.
21118
+ if (refuseCredentialShareIfBlocked(resolvedId))
21119
+ return;
20797
21120
  const relayMsgId = relayClient.sendAuto(resolvedId, message, threadId);
20798
21121
  const effectiveRelayThreadId = threadId ?? relayMsgId;
20799
21122
  // Canonical outbox write for the relay-delivery path — same shape as the