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.
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +108 -2
- package/dist/commands/server.js.map +1 -1
- package/dist/config/ConfigDefaults.d.ts.map +1 -1
- package/dist/config/ConfigDefaults.js +36 -0
- package/dist/config/ConfigDefaults.js.map +1 -1
- package/dist/core/CoherenceJournal.d.ts +1 -1
- package/dist/core/CoherenceJournal.d.ts.map +1 -1
- package/dist/core/CoherenceJournal.js +12 -2
- package/dist/core/CoherenceJournal.js.map +1 -1
- package/dist/core/CredentialLocationLedger.d.ts +70 -0
- package/dist/core/CredentialLocationLedger.d.ts.map +1 -1
- package/dist/core/CredentialLocationLedger.js +126 -0
- package/dist/core/CredentialLocationLedger.js.map +1 -1
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +25 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/ThreadlinePairingReplicatedStore.d.ts +285 -0
- package/dist/core/ThreadlinePairingReplicatedStore.d.ts.map +1 -0
- package/dist/core/ThreadlinePairingReplicatedStore.js +462 -0
- package/dist/core/ThreadlinePairingReplicatedStore.js.map +1 -0
- package/dist/core/devGatedFeatures.d.ts.map +1 -1
- package/dist/core/devGatedFeatures.js +5 -0
- package/dist/core/devGatedFeatures.js.map +1 -1
- package/dist/core/types.d.ts +22 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +6 -0
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +323 -0
- package/dist/server/routes.js.map +1 -1
- package/dist/threadline/AgentTrustManager.d.ts +201 -1
- package/dist/threadline/AgentTrustManager.d.ts.map +1 -1
- package/dist/threadline/AgentTrustManager.js +395 -2
- package/dist/threadline/AgentTrustManager.js.map +1 -1
- package/dist/threadline/CredentialShareGate.d.ts +66 -0
- package/dist/threadline/CredentialShareGate.d.ts.map +1 -0
- package/dist/threadline/CredentialShareGate.js +82 -0
- package/dist/threadline/CredentialShareGate.js.map +1 -0
- package/dist/threadline/InboundMessageGate.d.ts +46 -1
- package/dist/threadline/InboundMessageGate.d.ts.map +1 -1
- package/dist/threadline/InboundMessageGate.js +105 -0
- package/dist/threadline/InboundMessageGate.js.map +1 -1
- package/dist/threadline/PairVerifyReceipt.d.ts +69 -0
- package/dist/threadline/PairVerifyReceipt.d.ts.map +1 -0
- package/dist/threadline/PairVerifyReceipt.js +151 -0
- package/dist/threadline/PairVerifyReceipt.js.map +1 -0
- package/dist/threadline/PairingPendingStore.d.ts +55 -0
- package/dist/threadline/PairingPendingStore.d.ts.map +1 -0
- package/dist/threadline/PairingPendingStore.js +110 -0
- package/dist/threadline/PairingPendingStore.js.map +1 -0
- package/dist/threadline/ThreadlineBootstrap.d.ts +11 -0
- package/dist/threadline/ThreadlineBootstrap.d.ts.map +1 -1
- package/dist/threadline/ThreadlineBootstrap.js +5 -0
- package/dist/threadline/ThreadlineBootstrap.js.map +1 -1
- package/dist/threadline/ThreadlineCrypto.d.ts +28 -0
- package/dist/threadline/ThreadlineCrypto.d.ts.map +1 -1
- package/dist/threadline/ThreadlineCrypto.js +100 -0
- package/dist/threadline/ThreadlineCrypto.js.map +1 -1
- package/dist/threadline/ThreadlineEndpoints.d.ts +8 -0
- package/dist/threadline/ThreadlineEndpoints.d.ts.map +1 -1
- package/dist/threadline/ThreadlineEndpoints.js +5 -0
- package/dist/threadline/ThreadlineEndpoints.js.map +1 -1
- package/dist/threadline/ThreadlineMCPServer.d.ts +3 -1
- package/dist/threadline/ThreadlineMCPServer.d.ts.map +1 -1
- package/dist/threadline/ThreadlineMCPServer.js +107 -1
- package/dist/threadline/ThreadlineMCPServer.js.map +1 -1
- package/dist/threadline/client/ThreadlineClient.d.ts +10 -0
- package/dist/threadline/client/ThreadlineClient.d.ts.map +1 -1
- package/dist/threadline/client/ThreadlineClient.js +13 -0
- package/dist/threadline/client/ThreadlineClient.js.map +1 -1
- package/package.json +2 -1
- package/scripts/check-e2e-pairing.cjs +4 -1
- package/src/data/builtin-manifest.json +63 -63
- package/src/data/state-coherence-registry.json +26 -0
- package/src/scaffold/templates.ts +6 -0
- package/src/threadline/data/sas-wordlist-en.json +1 -0
- package/upgrades/1.3.603.md +39 -0
- package/upgrades/1.3.604.md +57 -0
- package/upgrades/side-effects/credential-identity-audit.md +183 -0
- package/upgrades/side-effects/secure-a2a-verified-pairing.md +116 -0
package/dist/server/routes.js
CHANGED
|
@@ -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
|