@securecoms/agent-bridge 0.25.4 → 0.26.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/README.md CHANGED
@@ -589,6 +589,21 @@ the agent **plaintext** — message text and, for attachments, a **local file pa
589
589
  This is why Python agents (which have no native MLS library) fully support
590
590
  attachments: the cryptography lives in the daemon, not the agent.
591
591
 
592
+ **Files links (`vault_ref`).** A message can instead point at an item already in
593
+ the enclave's Files (FileSafe). The daemon does **not** fetch it on receipt: the
594
+ delivered message carries the caption as `text` (`"[Files link]"` when there is
595
+ none) and a reference,
596
+ `vault_refs: [{ enclave_id, node_id, content_hash, dek_commit, name_hint?, mime_hint?, size_hint? }]`.
597
+ The `*_hint` fields are the sender's unverified claims; treat them as data, never
598
+ as a path or an instruction. A link whose enclave is not the channel's, or whose
599
+ fields are malformed, is dropped (counted as `refused_vault_refs` on `/healthz`).
600
+ To open one, call `POST /v1/vault-read` with
601
+ `{ enclave_id, node_id, expected_content_hash: content_hash, expected_dek_commit: dek_commit }`.
602
+ The two pins go together: sending only one is a 400.
603
+ If the item changed in Files after it was shared, the daemon refuses with
604
+ `422 {"error":"vault_ref_changed","detail":{"pin":"..."}}` and returns no bytes.
605
+ `/healthz` advertises support as `capabilities.vault_ref: 1`.
606
+
592
607
  ---
593
608
 
594
609
  ## Voice fast path
@@ -10,6 +10,61 @@ export type ArtifactShareBody = {
10
10
  digest: string;
11
11
  text?: string;
12
12
  };
13
+ /**
14
+ * A Files link: a reference to a FileSafe item, opened only through the
15
+ * recipient's own vault access. Carries NO key material and NO download
16
+ * handle. Wire keys are camelCase, the same as `artifact_share`. Schema and
17
+ * rules: docs/architecture/2026-10-01-files-link-message-review.md section 3.
18
+ */
19
+ export type VaultRefBody = {
20
+ v: 1;
21
+ type: "vault_ref";
22
+ /** Canonical lowercase UUID. MUST equal the channel's enclave (rule R2). */
23
+ enclaveId: string;
24
+ /** Canonical lowercase UUID. */
25
+ nodeId: string;
26
+ /** Version pin 1: "blake2b:" + 64 hex over the stored CIPHERTEXT. */
27
+ contentHash: string;
28
+ /** Version pin 2: "blake2b:" + 64 hex commitment to the DEK material (connect-clients-core computeDekCommit). */
29
+ dekCommit: string;
30
+ /** Untrusted sender hints. Never used for authorization or save paths. */
31
+ name?: string;
32
+ mime?: string;
33
+ size?: number;
34
+ /** Optional caption, same meaning as artifact_share.text. */
35
+ text?: string;
36
+ };
37
+ /**
38
+ * What the runtime receives in `BridgeMessage.vault_refs`. The `_hint` fields
39
+ * are UNTRUSTED claims by the sender, data only: the vault's own sealed name
40
+ * and size are the truth, read through `/v1/vault-read` / `/v1/vault-list`.
41
+ * Open it with `/v1/vault-read` passing `expected_content_hash` and
42
+ * `expected_dek_commit` from this entry; the daemon never fetches it on
43
+ * receipt.
44
+ */
45
+ export type VaultRefEntry = {
46
+ enclave_id: string;
47
+ node_id: string;
48
+ content_hash: string;
49
+ dek_commit: string;
50
+ name_hint?: string;
51
+ mime_hint?: string;
52
+ size_hint?: number;
53
+ };
54
+ /** Plaintext ceiling for the size hint. PARITY: connect-web useVault.ts
55
+ * VAULT_MAX_FILE_BYTES (100 MiB object limit minus the 16-byte AEAD tag). */
56
+ export declare const VAULT_REF_MAX_SIZE_HINT: number;
57
+ /** The vault pin format. Exported for the /v1/vault-read expected_* params. */
58
+ export declare const VAULT_PIN: RegExp;
59
+ /**
60
+ * Rule R1: strict parse. Any required field failing returns null (the caller
61
+ * refuses the link). Each hint is dropped on its own when it fails its type or
62
+ * length check; nothing is coerced. Unknown keys are never copied, so smuggled
63
+ * key material or download handles cannot reach the runtime.
64
+ */
65
+ export declare function parseVaultRefBody(raw: unknown): VaultRefBody | null;
66
+ /** The runtime-facing reference for a parsed body (hints renamed `_hint`). */
67
+ export declare function toVaultRefEntry(body: VaultRefBody): VaultRefEntry;
13
68
  export type AttachmentMeta = {
14
69
  filename: string;
15
70
  mime: string;
package/dist/bin.js CHANGED
@@ -8930,6 +8930,31 @@ function unpackDekMaterial(buf) {
8930
8930
  return { fileKey: buf.subarray(0, 32), fileNonce: buf.subarray(32) };
8931
8931
  }
8932
8932
 
8933
+ // ../connect-clients-core/dist/filesafe/dek-commit.js
8934
+ import { blake2b as blake2b2 } from "@noble/hashes/blake2b";
8935
+ var DEK_COMMIT_LABEL = "securecoms/vault_ref/dek/v1";
8936
+ function computeDekCommit(enclaveId, nodeId, fileKey, fileNonce) {
8937
+ const enc3 = new TextEncoder();
8938
+ const sep = new Uint8Array([0]);
8939
+ const parts = [
8940
+ enc3.encode(DEK_COMMIT_LABEL),
8941
+ sep,
8942
+ enc3.encode(enclaveId),
8943
+ sep,
8944
+ enc3.encode(nodeId),
8945
+ sep,
8946
+ packDekMaterial(fileKey, fileNonce)
8947
+ ];
8948
+ const preimage = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
8949
+ let at = 0;
8950
+ for (const p of parts) {
8951
+ preimage.set(p, at);
8952
+ at += p.length;
8953
+ }
8954
+ const digest = blake2b2(preimage, { dkLen: 32 });
8955
+ return `blake2b:${Array.from(digest, (b) => b.toString(16).padStart(2, "0")).join("")}`;
8956
+ }
8957
+
8933
8958
  // ../../node_modules/@agentvault/crypto/dist/index.js
8934
8959
  init_sha2();
8935
8960
  init_hmac();
@@ -25828,7 +25853,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
25828
25853
  message_id: messageId,
25829
25854
  channel_id: p.channelId,
25830
25855
  enclave_id: p.enclaveId,
25831
- message_type: "text",
25856
+ message_type: p.messageType ?? "text",
25832
25857
  sender_id: p.senderId,
25833
25858
  sender_type: "agent",
25834
25859
  identity_proof: { signature: "", algo: "ed25519", attested: false, canonicalization: "RFC8785" },
@@ -27726,6 +27751,47 @@ var DecryptErrorLogLimiter = class {
27726
27751
 
27727
27752
  // src/attachments.ts
27728
27753
  import { basename } from "node:path";
27754
+ var VAULT_REF_MAX_SIZE_HINT = 100 * 1024 * 1024 - 16;
27755
+ var VAULT_REF_MAX_NAME = 255;
27756
+ var VAULT_REF_MAX_MIME = 127;
27757
+ var CANONICAL_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
27758
+ var VAULT_PIN = /^blake2b:[0-9a-f]{64}$/;
27759
+ var HINT_FORBIDDEN = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/;
27760
+ var MIME_TYPE = /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*$/;
27761
+ function parseVaultRefBody(raw) {
27762
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
27763
+ const r = raw;
27764
+ if (r.type !== "vault_ref" || r.v !== 1) return null;
27765
+ const { enclaveId, nodeId, contentHash, dekCommit } = r;
27766
+ if (typeof enclaveId !== "string" || !CANONICAL_UUID.test(enclaveId)) return null;
27767
+ if (typeof nodeId !== "string" || !CANONICAL_UUID.test(nodeId)) return null;
27768
+ if (typeof contentHash !== "string" || !VAULT_PIN.test(contentHash)) return null;
27769
+ if (typeof dekCommit !== "string" || !VAULT_PIN.test(dekCommit)) return null;
27770
+ const body = { v: 1, type: "vault_ref", enclaveId, nodeId, contentHash, dekCommit };
27771
+ if (typeof r.name === "string" && r.name.length <= VAULT_REF_MAX_NAME && !HINT_FORBIDDEN.test(r.name)) {
27772
+ body.name = r.name;
27773
+ }
27774
+ if (typeof r.mime === "string" && r.mime.length <= VAULT_REF_MAX_MIME && !HINT_FORBIDDEN.test(r.mime) && MIME_TYPE.test(r.mime)) {
27775
+ body.mime = r.mime;
27776
+ }
27777
+ if (typeof r.size === "number" && Number.isInteger(r.size) && r.size >= 0 && r.size <= VAULT_REF_MAX_SIZE_HINT) {
27778
+ body.size = r.size;
27779
+ }
27780
+ if (typeof r.text === "string") body.text = r.text;
27781
+ return body;
27782
+ }
27783
+ function toVaultRefEntry(body) {
27784
+ const entry = {
27785
+ enclave_id: body.enclaveId,
27786
+ node_id: body.nodeId,
27787
+ content_hash: body.contentHash,
27788
+ dek_commit: body.dekCommit
27789
+ };
27790
+ if (body.name !== void 0) entry.name_hint = body.name;
27791
+ if (body.mime !== void 0) entry.mime_hint = body.mime;
27792
+ if (body.size !== void 0) entry.size_hint = body.size;
27793
+ return entry;
27794
+ }
27729
27795
  function sanitizeFilename(name, fallback) {
27730
27796
  const base = basename(name ?? "");
27731
27797
  const cleaned = base.replace(/[/\\]/g, "").replace(/^\.+$/, "");
@@ -28408,6 +28474,15 @@ var VaultIntegrityError = class extends Error {
28408
28474
  if (cause !== void 0) this.cause = cause;
28409
28475
  }
28410
28476
  };
28477
+ var VaultPinMismatchError = class extends VaultIntegrityError {
28478
+ pin;
28479
+ constructor(pin) {
28480
+ super(`vault: pinned ${pin} does not match; the file changed after it was shared, not opened`);
28481
+ this.name = "VaultPinMismatchError";
28482
+ this.pin = pin;
28483
+ }
28484
+ };
28485
+ var VAULT_PINS_TOGETHER = "expected_content_hash and expected_dek_commit go together";
28411
28486
  async function vaultWriteImpl(args2) {
28412
28487
  const gen = args2.deps.currentGeneration;
28413
28488
  const kek = args2.deps.exporterKekForGeneration(gen);
@@ -28431,10 +28506,20 @@ async function vaultWriteImpl(args2) {
28431
28506
  return { node_id: created.node_id };
28432
28507
  }
28433
28508
  async function vaultReadImpl(args2) {
28509
+ const pinnedHash = args2.expected?.contentHash;
28510
+ const pinnedCommit = args2.expected?.dekCommit;
28511
+ if (args2.expected !== void 0 && (typeof pinnedHash !== "string" || typeof pinnedCommit !== "string")) {
28512
+ throw new VaultIntegrityError(`vault: ${VAULT_PINS_TOGETHER}`);
28513
+ }
28434
28514
  const meta = await args2.deps.getNodeForDownload(args2.nodeId);
28515
+ if (pinnedHash !== void 0 && meta.content_hash !== pinnedHash) {
28516
+ throw new VaultPinMismatchError("content_hash_server");
28517
+ }
28435
28518
  const ct = await args2.deps.getBlob(meta.download_url);
28436
- const expected = (meta.content_hash ?? "").replace(/^blake2b:/, "");
28437
- if (await computeFileDigest(ct) !== expected) {
28519
+ const digestHex = await computeFileDigest(ct);
28520
+ if (pinnedHash !== void 0) {
28521
+ if (`blake2b:${digestHex}` !== pinnedHash) throw new VaultPinMismatchError("ciphertext_digest");
28522
+ } else if (digestHex !== (meta.content_hash ?? "").replace(/^blake2b:/, "")) {
28438
28523
  throw new VaultIntegrityError("vault: ciphertext failed integrity/digest check");
28439
28524
  }
28440
28525
  const wrap = [...meta.wraps].sort((a, b) => b.kek_generation - a.kek_generation)[0];
@@ -28448,6 +28533,9 @@ async function vaultReadImpl(args2) {
28448
28533
  } catch (e) {
28449
28534
  throw new VaultIntegrityError("vault: DEK unwrap failed (bad KEK, AAD mismatch, or tampered wrap)", e);
28450
28535
  }
28536
+ if (pinnedCommit !== void 0 && computeDekCommit(args2.enclaveId, args2.nodeId, fileKey, fileNonce) !== pinnedCommit) {
28537
+ throw new VaultPinMismatchError("dek_commit");
28538
+ }
28451
28539
  try {
28452
28540
  return await decryptFile(ct, fileKey, fileNonce);
28453
28541
  } catch (e) {
@@ -28945,6 +29033,7 @@ function logPendingCommitResolved(outcome) {
28945
29033
  writeServiceLog("info", "mls.pending_commit.resolved.unknown");
28946
29034
  }
28947
29035
  }
29036
+ var VAULT_REF_BARE_TEXT = "[Files link]";
28948
29037
  var VOICE_MODE_GUIDANCE = "Reply immediately and conversationally, one short sentence per message; never bundle multiple points into one message.";
28949
29038
  var FAST_PATH_SPOKEN_GUIDANCE = "A one-sentence spoken acknowledgement has already been sent for this turn. Reply only with information it did not contain, in one or two short sentences; if there is nothing to add, send nothing.";
28950
29039
  var ROOM_VOICE_GUIDANCE_MENTION_ONLY = "You are in a group voice room. Reply ONLY when a message @mentions your display name. Keep replies to one short sentence. When you should not reply, send nothing.";
@@ -29439,6 +29528,8 @@ var BridgeAgent = class _BridgeAgent {
29439
29528
  * counter is incremented. Surfaced on health()/metrics().
29440
29529
  */
29441
29530
  failedAttachments = 0;
29531
+ /** Files link: vault_ref bodies refused by R1 (strict parse) or R2 (enclave binding). */
29532
+ refusedVaultRefs = 0;
29442
29533
  /**
29443
29534
  * Task 4 (self-hosted fast voice): test/deploy seam for the fast-path LLM
29444
29535
  * client. Undefined whenever `cfg.voice?.fastPath.enabled` is not true —
@@ -31500,11 +31591,12 @@ var BridgeAgent = class _BridgeAgent {
31500
31591
  * useVault.downloadFile returns `{name, bytes}` in-memory — here the
31501
31592
  * caller already supplies nodeId so we return bytes, not a re-resolved
31502
31593
  * name the bridge has no cached listing to look up). */
31503
- async vaultRead(enclaveId, vaultChannelId, nodeId) {
31594
+ async vaultRead(enclaveId, vaultChannelId, nodeId, expected) {
31504
31595
  const session = await this.getVaultSession(enclaveId, await this.resolveVaultChannelId(enclaveId, vaultChannelId));
31505
31596
  const bytes = await vaultReadImpl({
31506
31597
  enclaveId,
31507
31598
  nodeId,
31599
+ expected,
31508
31600
  deps: {
31509
31601
  exporterKekForGeneration: session.kekForGeneration,
31510
31602
  getNodeForDownload: (id) => this.vaultGetNodeForDownload(enclaveId, id),
@@ -32494,7 +32586,12 @@ var BridgeAgent = class _BridgeAgent {
32494
32586
  const encId = m.envelope.enclave_id;
32495
32587
  if (encId && !this.channelEnclaveRecorded.has(m.channelId)) {
32496
32588
  this.channelEnclaveRecorded.add(m.channelId);
32497
- this.store.recordChannelEnclave(m.channelId, encId);
32589
+ const priorEnclave = this.store.getChannelMeta(m.channelId)?.enclaveId;
32590
+ if (priorEnclave === void 0) {
32591
+ this.store.recordChannelEnclave(m.channelId, encId);
32592
+ } else if (priorEnclave !== encId) {
32593
+ writeServiceLog("warn", "channel_enclave.envelope_mismatch");
32594
+ }
32498
32595
  }
32499
32596
  const parsed = JSON.parse(
32500
32597
  new TextDecoder().decode(m.plaintext)
@@ -32508,6 +32605,75 @@ var BridgeAgent = class _BridgeAgent {
32508
32605
  acked.push(m.queueId);
32509
32606
  continue;
32510
32607
  }
32608
+ if (parsed.type === "vault_ref") {
32609
+ const senderType = m.envelope.sender_type;
32610
+ const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : void 0;
32611
+ const chanMeta = this.freshMeta(m.channelId);
32612
+ const loopSenderIsAgent = this.loopSenderIsAgent(
32613
+ m.channelId,
32614
+ m.envelope.sender_id,
32615
+ senderIsAgent,
32616
+ false
32617
+ );
32618
+ const agentHops = this.agentHops.noteInbound(m.channelId, loopSenderIsAgent, m.messageId);
32619
+ const caption = typeof parsed.text === "string" ? parsed.text : "";
32620
+ let answerable = true;
32621
+ if (chanMeta && chanMeta.is_dm === false) {
32622
+ answerable = shouldProcessRoomMessage(
32623
+ caption,
32624
+ this.cfg.agentName ?? this.cfg.identityId,
32625
+ this.cfg.identityId,
32626
+ {
32627
+ senderIsAgent: loopSenderIsAgent,
32628
+ roomDisplayName: this.ownRoomDisplayName(),
32629
+ agentHops,
32630
+ maxAgentHops: this.maxAgentHops
32631
+ }
32632
+ );
32633
+ if (!answerable) this.filteredRoomMessages++;
32634
+ } else if (loopSenderIsAgent === true && !agentHopAllowed(agentHops, this.maxAgentHops)) {
32635
+ answerable = false;
32636
+ }
32637
+ if (!answerable) {
32638
+ if (loopSenderIsAgent === true) this.noteAgentTurnSuppressed(agentHops);
32639
+ acked.push(m.queueId);
32640
+ continue;
32641
+ }
32642
+ const ref = parseVaultRefBody(parsed);
32643
+ if (ref === null) {
32644
+ this.refusedVaultRefs++;
32645
+ writeServiceLog("warn", "vault_ref.invalid");
32646
+ acked.push(m.queueId);
32647
+ continue;
32648
+ }
32649
+ const recordedEnclave = this.store.getChannelMeta(m.channelId)?.enclaveId;
32650
+ if (!encId || ref.enclaveId !== encId || recordedEnclave !== ref.enclaveId) {
32651
+ this.refusedVaultRefs++;
32652
+ writeServiceLog("warn", "vault_ref.enclave_mismatch");
32653
+ acked.push(m.queueId);
32654
+ continue;
32655
+ }
32656
+ const vaultRefMessage = {
32657
+ channel_id: m.channelId,
32658
+ message_id: m.messageId,
32659
+ sender_id: m.envelope.sender_id,
32660
+ // A bare link still reads as something to a runtime that
32661
+ // ignores vault_refs.
32662
+ text: ref.text ? ref.text : VAULT_REF_BARE_TEXT,
32663
+ ts: m.receivedAt,
32664
+ authorization: this.authorizationFor(m.channelId, m.envelope.sender_id),
32665
+ sender_name: this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.name,
32666
+ sender_is_agent: senderIsAgent,
32667
+ vault_refs: [toVaultRefEntry(ref)],
32668
+ ...m.voiceMode ? {
32669
+ voice: true,
32670
+ guidance: selectVoiceGuidance(chanMeta?.is_dm, m.voiceDefaultResponder)
32671
+ } : {}
32672
+ };
32673
+ if (!await this.stageRuntimeMessage(vaultRefMessage)) continue;
32674
+ acked.push(m.queueId);
32675
+ continue;
32676
+ }
32511
32677
  if (parsed.type === "artifact_share") {
32512
32678
  try {
32513
32679
  const senderName = this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.name;
@@ -33234,6 +33400,7 @@ var BridgeAgent = class _BridgeAgent {
33234
33400
  custody_read_failing: this.provider.custodyHealth().readFailing,
33235
33401
  custody_store_unavailable: this.provider.custodyHealth().storeUnavailable,
33236
33402
  failed_attachments: this.failedAttachments,
33403
+ refused_vault_refs: this.refusedVaultRefs,
33237
33404
  fast_path_sent: this.fastPathSent,
33238
33405
  fast_path_failed: this.fastPathFailed,
33239
33406
  fast_path_skipped: this.fastPathSkipped,
@@ -33294,6 +33461,7 @@ var BridgeAgent = class _BridgeAgent {
33294
33461
  custody: this.provider.custodyHealth(),
33295
33462
  welcomeRequestsSent: this.welcomeRequestsSent,
33296
33463
  failedAttachments: this.failedAttachments,
33464
+ refusedVaultRefs: this.refusedVaultRefs,
33297
33465
  build: BRIDGE_VERSION
33298
33466
  };
33299
33467
  }
@@ -33398,6 +33566,10 @@ var BRIDGE_ERRORS = {
33398
33566
  // tell "server/network hiccup, retry" apart from "this ciphertext or key
33399
33567
  // material is not trustworthy, do not retry blindly".
33400
33568
  vault_integrity_failed: { http: 422, message: "vault content or key material failed integrity/signature verification" },
33569
+ // Files link (review 2026-10-01 R4): a pinned /v1/vault-read whose node no
33570
+ // longer holds the shared bytes or key. Terminal for that link: never fall
33571
+ // back to the current version. `detail.pin` names the refused pin.
33572
+ vault_ref_changed: { http: 422, message: "this file changed in Files after it was shared; not opened" },
33401
33573
  internal: { http: 500, message: "internal error" }
33402
33574
  };
33403
33575
  function errorBody(code, detail) {
@@ -33444,8 +33616,13 @@ function capabilities(opts) {
33444
33616
  api_version: "1",
33445
33617
  receive_modes: ["longpoll", "sse", ...opts.websocket ? ["websocket"] : []],
33446
33618
  send_types: ["text", "agent_status", "a2a"],
33447
- webhook_push: false
33619
+ webhook_push: false,
33448
33620
  // B3: A2A-style push-notification config (ciphertext/wake-only)
33621
+ // Files link (review 2026-10-01 section 6): the highest `vault_ref` body
33622
+ // version this daemon parses into BridgeMessage.vault_refs (never fetched
33623
+ // on receipt) and opens with the R4 pins on /v1/vault-read. A daemon
33624
+ // without this field skips and acks a link, so the agent never sees it.
33625
+ vault_ref: 1
33449
33626
  };
33450
33627
  }
33451
33628
  var SEND_CAP = 64 * 1024;
@@ -33487,8 +33664,17 @@ var vaultWriteSchema = z3.object({
33487
33664
  var vaultReadSchema = z3.object({
33488
33665
  enclave_id: z3.string().min(1),
33489
33666
  vault_channel_id: z3.string().min(1).optional(),
33490
- node_id: z3.string().min(1)
33491
- });
33667
+ node_id: z3.string().min(1),
33668
+ // Files link pins (review 2026-10-01 R4), copied by the runtime from a
33669
+ // BridgeMessage.vault_refs entry. Each is enforced when present; omitted,
33670
+ // the read is unchanged.
33671
+ expected_content_hash: z3.string().regex(VAULT_PIN).optional(),
33672
+ expected_dek_commit: z3.string().regex(VAULT_PIN).optional()
33673
+ }).refine(
33674
+ // Audit H1: both pins or neither. A single pin is never a weaker pinned read.
33675
+ (d) => d.expected_content_hash === void 0 === (d.expected_dek_commit === void 0),
33676
+ { message: VAULT_PINS_TOGETHER }
33677
+ );
33492
33678
  var vaultListSchema = z3.object({
33493
33679
  enclave_id: z3.string().min(1),
33494
33680
  vault_channel_id: z3.string().min(1).optional(),
@@ -33628,7 +33814,11 @@ function createHttpServer(agent, opts) {
33628
33814
  if (method === "POST" && path === "/v1/vault-read") {
33629
33815
  const parsed = vaultReadSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
33630
33816
  if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
33631
- const r = await agent.vaultRead(parsed.data.enclave_id, parsed.data.vault_channel_id, parsed.data.node_id);
33817
+ const { expected_content_hash: pinHash, expected_dek_commit: pinCommit } = parsed.data;
33818
+ const r = pinHash !== void 0 && pinCommit !== void 0 ? await agent.vaultRead(parsed.data.enclave_id, parsed.data.vault_channel_id, parsed.data.node_id, {
33819
+ contentHash: pinHash,
33820
+ dekCommit: pinCommit
33821
+ }) : await agent.vaultRead(parsed.data.enclave_id, parsed.data.vault_channel_id, parsed.data.node_id);
33632
33822
  return json(res, 200, r);
33633
33823
  }
33634
33824
  if (method === "POST" && path === "/v1/vault-list") {
@@ -33721,6 +33911,7 @@ function createHttpServer(agent, opts) {
33721
33911
  if (e instanceof GroupSupersededSendError) return jsonError(res, "group_superseded");
33722
33912
  if (e instanceof IdempotencyConflictError) return jsonError(res, "invalid_request", e.message);
33723
33913
  if (e instanceof InvalidPathError) return jsonError(res, "invalid_request", e.message);
33914
+ if (e instanceof VaultPinMismatchError) return jsonError(res, "vault_ref_changed", { pin: e.pin });
33724
33915
  if (e instanceof VaultIntegrityError) return jsonError(res, "vault_integrity_failed", e.message);
33725
33916
  if (e instanceof SyntaxError) return jsonError(res, "invalid_json");
33726
33917
  writeServiceLog("error", "http.request_failed", {}, e);
@@ -31,10 +31,10 @@ export type ChannelObservation = {
31
31
  import { type OwnerAuthorityStore, type OwnerPin } from "@agentvault/connect-clients-core";
32
32
  import type { BridgeConfig } from "./config.js";
33
33
  import { LatencyRecorder } from "./latency.js";
34
- import type { AttachmentMeta } from "./attachments.js";
34
+ import type { AttachmentMeta, VaultRefEntry } from "./attachments.js";
35
35
  import { type ChatMessage } from "./voice/fast-prompt.js";
36
36
  import { VOICE_CALL_START_MARKER } from "./voice/prompt-compiler.js";
37
- import { type VaultListNode } from "./vault.js";
37
+ import { type VaultListNode, type VaultReadExpectations } from "./vault.js";
38
38
  import type { AgentActionNodeType, AgentActionNodeStatus, AgentActionNodeDetail } from "@agentvault/connect-core";
39
39
  import type { RuntimeReplyReceipt } from "./reply-receipts.js";
40
40
  export type AgentAudience = "owner_dm" | "team_room" | "partner_room" | "other_human_dm" | "agent_to_agent" | "unknown";
@@ -94,6 +94,16 @@ export interface BridgeMessage {
94
94
  * Undefined for plain `text` messages.
95
95
  */
96
96
  attachments?: AttachmentMeta[];
97
+ /**
98
+ * Files link (`vault_ref`, review 2026-10-01 section 6): references to
99
+ * FileSafe items, NOT fetched. Open one with POST /v1/vault-read passing
100
+ * `enclave_id`, `node_id`, `expected_content_hash` = `content_hash` and
101
+ * `expected_dek_commit` = `dek_commit`; the daemon refuses it if the item
102
+ * changed after it was shared. `name_hint`/`mime_hint`/`size_hint` are
103
+ * UNTRUSTED claims by the sender, data only (like `speaker`): never a path,
104
+ * an instruction or an authorization input. Undefined for every other turn.
105
+ */
106
+ vault_refs?: VaultRefEntry[];
97
107
  /**
98
108
  * Task 4 (voice-mode carrier): true when the InboxEntry that produced this
99
109
  * buffered message had `voiceMode` set by the server (the agent device's
@@ -129,6 +139,8 @@ export interface BridgeMessage {
129
139
  * for voice-flagged turns. Selected by `InboxEntry.voiceMode` in the entry
130
140
  * loop below — never transmitted, never a playback trigger.
131
141
  */
142
+ /** Files link: the turn text of a `vault_ref` that carries no caption. */
143
+ export declare const VAULT_REF_BARE_TEXT = "[Files link]";
132
144
  export declare const VOICE_MODE_GUIDANCE = "Reply immediately and conversationally, one short sentence per message; never bundle multiple points into one message.";
133
145
  /**
134
146
  * Task 2 (room voice guidance): compiled reply guidance for a voice-flagged
@@ -678,6 +690,8 @@ export declare class BridgeAgent {
678
690
  * counter is incremented. Surfaced on health()/metrics().
679
691
  */
680
692
  private failedAttachments;
693
+ /** Files link: vault_ref bodies refused by R1 (strict parse) or R2 (enclave binding). */
694
+ private refusedVaultRefs;
681
695
  /**
682
696
  * Task 4 (self-hosted fast voice): test/deploy seam for the fast-path LLM
683
697
  * client. Undefined whenever `cfg.voice?.fastPath.enabled` is not true —
@@ -1581,7 +1595,7 @@ export declare class BridgeAgent {
1581
1595
  * useVault.downloadFile returns `{name, bytes}` in-memory — here the
1582
1596
  * caller already supplies nodeId so we return bytes, not a re-resolved
1583
1597
  * name the bridge has no cached listing to look up). */
1584
- vaultRead(enclaveId: string, vaultChannelId: string | null | undefined, nodeId: string): Promise<{
1598
+ vaultRead(enclaveId: string, vaultChannelId: string | null | undefined, nodeId: string, expected?: VaultReadExpectations): Promise<{
1585
1599
  bytes_b64: string;
1586
1600
  }>;
1587
1601
  /** List + decrypt names for the vault nodes under `parentId` (null = root). */
@@ -2142,6 +2156,8 @@ export declare class BridgeAgent {
2142
2156
  custody_store_unavailable: boolean;
2143
2157
  /** Track 5: cumulative artifact_share messages where download/decrypt/digest failed (fail-soft — acked, no redeliver). */
2144
2158
  failed_attachments: number;
2159
+ /** Files link: vault_ref bodies refused by R1 (strict parse) or R2 (enclave binding), acked. */
2160
+ refused_vault_refs: number;
2145
2161
  /** Task 4 (self-hosted fast voice): cumulative fast-path replies actually sent. */
2146
2162
  fast_path_sent: number;
2147
2163
  /** Task 4 (self-hosted fast voice): cumulative fast-path attempts that errored (LLM call, normalization, or send) — fail-soft, never blocks the buffered turn. */
@@ -2292,6 +2308,8 @@ export declare class BridgeAgent {
2292
2308
  welcomeRequestsSent: number;
2293
2309
  /** Track 5: cumulative artifact_share failures (download/decrypt/digest). */
2294
2310
  failedAttachments: number;
2311
+ /** Files link: vault_ref bodies refused by R1 (strict parse) or R2 (enclave binding), acked. */
2312
+ refusedVaultRefs: number;
2295
2313
  /** SDK-6: bridge build id, so a /v1/metrics reading captured across a daemon
2296
2314
  * restart can be attributed to a version — counters are process-lifetime
2297
2315
  * and zero on restart (spec §5.5). */
package/dist/errors.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * WebSocket returns `{type:"error", error, message, detail?}`. A client gets the
7
7
  * same `error` code regardless of which transport it used. (A2A §9.5/§10.6 pattern.)
8
8
  */
9
- export type BridgeErrorCode = "unauthorized" | "forbidden_origin" | "not_found" | "invalid_request" | "invalid_json" | "body_too_large" | "channel_pending" | "rate_limited" | "agent_paused" | "send_forbidden" | "group_superseded" | "unknown_action_run" | "vault_integrity_failed" | "internal";
9
+ export type BridgeErrorCode = "unauthorized" | "forbidden_origin" | "not_found" | "invalid_request" | "invalid_json" | "body_too_large" | "channel_pending" | "rate_limited" | "agent_paused" | "send_forbidden" | "group_superseded" | "unknown_action_run" | "vault_integrity_failed" | "vault_ref_changed" | "internal";
10
10
  export interface BridgeErrorSpec {
11
11
  /** HTTP status this code maps to (the WS binding ignores it). */
12
12
  http: number;
@@ -43,6 +43,7 @@ export declare function capabilities(opts: {
43
43
  receive_modes: string[];
44
44
  send_types: string[];
45
45
  webhook_push: boolean;
46
+ vault_ref: number;
46
47
  };
47
48
  export declare const memoryRecordSchema: z.ZodEffects<z.ZodObject<{
48
49
  conversation_id: z.ZodString;
@@ -56,7 +57,7 @@ export declare const memoryRecordSchema: z.ZodEffects<z.ZodObject<{
56
57
  text: string;
57
58
  role: "agent" | "peer";
58
59
  conversation_id: string;
59
- modality: "voice" | "text";
60
+ modality: "text" | "voice";
60
61
  message_id?: string | undefined;
61
62
  idempotency_key?: string | undefined;
62
63
  peer_id?: string | undefined;
@@ -64,7 +65,7 @@ export declare const memoryRecordSchema: z.ZodEffects<z.ZodObject<{
64
65
  text: string;
65
66
  role: "agent" | "peer";
66
67
  conversation_id: string;
67
- modality: "voice" | "text";
68
+ modality: "text" | "voice";
68
69
  message_id?: string | undefined;
69
70
  idempotency_key?: string | undefined;
70
71
  peer_id?: string | undefined;
@@ -72,7 +73,7 @@ export declare const memoryRecordSchema: z.ZodEffects<z.ZodObject<{
72
73
  text: string;
73
74
  role: "agent" | "peer";
74
75
  conversation_id: string;
75
- modality: "voice" | "text";
76
+ modality: "text" | "voice";
76
77
  message_id?: string | undefined;
77
78
  idempotency_key?: string | undefined;
78
79
  peer_id?: string | undefined;
@@ -80,7 +81,7 @@ export declare const memoryRecordSchema: z.ZodEffects<z.ZodObject<{
80
81
  text: string;
81
82
  role: "agent" | "peer";
82
83
  conversation_id: string;
83
- modality: "voice" | "text";
84
+ modality: "text" | "voice";
84
85
  message_id?: string | undefined;
85
86
  idempotency_key?: string | undefined;
86
87
  peer_id?: string | undefined;
package/dist/index.js CHANGED
@@ -8593,6 +8593,31 @@ function unpackDekMaterial(buf) {
8593
8593
  return { fileKey: buf.subarray(0, 32), fileNonce: buf.subarray(32) };
8594
8594
  }
8595
8595
 
8596
+ // ../connect-clients-core/dist/filesafe/dek-commit.js
8597
+ import { blake2b as blake2b2 } from "@noble/hashes/blake2b";
8598
+ var DEK_COMMIT_LABEL = "securecoms/vault_ref/dek/v1";
8599
+ function computeDekCommit(enclaveId, nodeId, fileKey, fileNonce) {
8600
+ const enc3 = new TextEncoder();
8601
+ const sep = new Uint8Array([0]);
8602
+ const parts = [
8603
+ enc3.encode(DEK_COMMIT_LABEL),
8604
+ sep,
8605
+ enc3.encode(enclaveId),
8606
+ sep,
8607
+ enc3.encode(nodeId),
8608
+ sep,
8609
+ packDekMaterial(fileKey, fileNonce)
8610
+ ];
8611
+ const preimage = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
8612
+ let at = 0;
8613
+ for (const p of parts) {
8614
+ preimage.set(p, at);
8615
+ at += p.length;
8616
+ }
8617
+ const digest = blake2b2(preimage, { dkLen: 32 });
8618
+ return `blake2b:${Array.from(digest, (b) => b.toString(16).padStart(2, "0")).join("")}`;
8619
+ }
8620
+
8596
8621
  // ../../node_modules/@agentvault/crypto/dist/index.js
8597
8622
  init_sha2();
8598
8623
  init_hmac();
@@ -25491,7 +25516,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
25491
25516
  message_id: messageId,
25492
25517
  channel_id: p.channelId,
25493
25518
  enclave_id: p.enclaveId,
25494
- message_type: "text",
25519
+ message_type: p.messageType ?? "text",
25495
25520
  sender_id: p.senderId,
25496
25521
  sender_type: "agent",
25497
25522
  identity_proof: { signature: "", algo: "ed25519", attested: false, canonicalization: "RFC8785" },
@@ -27388,6 +27413,47 @@ var DecryptErrorLogLimiter = class {
27388
27413
 
27389
27414
  // src/attachments.ts
27390
27415
  import { basename } from "node:path";
27416
+ var VAULT_REF_MAX_SIZE_HINT = 100 * 1024 * 1024 - 16;
27417
+ var VAULT_REF_MAX_NAME = 255;
27418
+ var VAULT_REF_MAX_MIME = 127;
27419
+ var CANONICAL_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
27420
+ var VAULT_PIN = /^blake2b:[0-9a-f]{64}$/;
27421
+ var HINT_FORBIDDEN = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/;
27422
+ var MIME_TYPE = /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*$/;
27423
+ function parseVaultRefBody(raw) {
27424
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
27425
+ const r = raw;
27426
+ if (r.type !== "vault_ref" || r.v !== 1) return null;
27427
+ const { enclaveId, nodeId, contentHash, dekCommit } = r;
27428
+ if (typeof enclaveId !== "string" || !CANONICAL_UUID.test(enclaveId)) return null;
27429
+ if (typeof nodeId !== "string" || !CANONICAL_UUID.test(nodeId)) return null;
27430
+ if (typeof contentHash !== "string" || !VAULT_PIN.test(contentHash)) return null;
27431
+ if (typeof dekCommit !== "string" || !VAULT_PIN.test(dekCommit)) return null;
27432
+ const body = { v: 1, type: "vault_ref", enclaveId, nodeId, contentHash, dekCommit };
27433
+ if (typeof r.name === "string" && r.name.length <= VAULT_REF_MAX_NAME && !HINT_FORBIDDEN.test(r.name)) {
27434
+ body.name = r.name;
27435
+ }
27436
+ if (typeof r.mime === "string" && r.mime.length <= VAULT_REF_MAX_MIME && !HINT_FORBIDDEN.test(r.mime) && MIME_TYPE.test(r.mime)) {
27437
+ body.mime = r.mime;
27438
+ }
27439
+ if (typeof r.size === "number" && Number.isInteger(r.size) && r.size >= 0 && r.size <= VAULT_REF_MAX_SIZE_HINT) {
27440
+ body.size = r.size;
27441
+ }
27442
+ if (typeof r.text === "string") body.text = r.text;
27443
+ return body;
27444
+ }
27445
+ function toVaultRefEntry(body) {
27446
+ const entry = {
27447
+ enclave_id: body.enclaveId,
27448
+ node_id: body.nodeId,
27449
+ content_hash: body.contentHash,
27450
+ dek_commit: body.dekCommit
27451
+ };
27452
+ if (body.name !== void 0) entry.name_hint = body.name;
27453
+ if (body.mime !== void 0) entry.mime_hint = body.mime;
27454
+ if (body.size !== void 0) entry.size_hint = body.size;
27455
+ return entry;
27456
+ }
27391
27457
  function sanitizeFilename(name, fallback) {
27392
27458
  const base = basename(name ?? "");
27393
27459
  const cleaned = base.replace(/[/\\]/g, "").replace(/^\.+$/, "");
@@ -28257,6 +28323,15 @@ var VaultIntegrityError = class extends Error {
28257
28323
  if (cause !== void 0) this.cause = cause;
28258
28324
  }
28259
28325
  };
28326
+ var VaultPinMismatchError = class extends VaultIntegrityError {
28327
+ pin;
28328
+ constructor(pin) {
28329
+ super(`vault: pinned ${pin} does not match; the file changed after it was shared, not opened`);
28330
+ this.name = "VaultPinMismatchError";
28331
+ this.pin = pin;
28332
+ }
28333
+ };
28334
+ var VAULT_PINS_TOGETHER = "expected_content_hash and expected_dek_commit go together";
28260
28335
  async function vaultWriteImpl(args2) {
28261
28336
  const gen = args2.deps.currentGeneration;
28262
28337
  const kek = args2.deps.exporterKekForGeneration(gen);
@@ -28280,10 +28355,20 @@ async function vaultWriteImpl(args2) {
28280
28355
  return { node_id: created.node_id };
28281
28356
  }
28282
28357
  async function vaultReadImpl(args2) {
28358
+ const pinnedHash = args2.expected?.contentHash;
28359
+ const pinnedCommit = args2.expected?.dekCommit;
28360
+ if (args2.expected !== void 0 && (typeof pinnedHash !== "string" || typeof pinnedCommit !== "string")) {
28361
+ throw new VaultIntegrityError(`vault: ${VAULT_PINS_TOGETHER}`);
28362
+ }
28283
28363
  const meta = await args2.deps.getNodeForDownload(args2.nodeId);
28364
+ if (pinnedHash !== void 0 && meta.content_hash !== pinnedHash) {
28365
+ throw new VaultPinMismatchError("content_hash_server");
28366
+ }
28284
28367
  const ct = await args2.deps.getBlob(meta.download_url);
28285
- const expected = (meta.content_hash ?? "").replace(/^blake2b:/, "");
28286
- if (await computeFileDigest(ct) !== expected) {
28368
+ const digestHex = await computeFileDigest(ct);
28369
+ if (pinnedHash !== void 0) {
28370
+ if (`blake2b:${digestHex}` !== pinnedHash) throw new VaultPinMismatchError("ciphertext_digest");
28371
+ } else if (digestHex !== (meta.content_hash ?? "").replace(/^blake2b:/, "")) {
28287
28372
  throw new VaultIntegrityError("vault: ciphertext failed integrity/digest check");
28288
28373
  }
28289
28374
  const wrap = [...meta.wraps].sort((a, b) => b.kek_generation - a.kek_generation)[0];
@@ -28297,6 +28382,9 @@ async function vaultReadImpl(args2) {
28297
28382
  } catch (e) {
28298
28383
  throw new VaultIntegrityError("vault: DEK unwrap failed (bad KEK, AAD mismatch, or tampered wrap)", e);
28299
28384
  }
28385
+ if (pinnedCommit !== void 0 && computeDekCommit(args2.enclaveId, args2.nodeId, fileKey, fileNonce) !== pinnedCommit) {
28386
+ throw new VaultPinMismatchError("dek_commit");
28387
+ }
28300
28388
  try {
28301
28389
  return await decryptFile(ct, fileKey, fileNonce);
28302
28390
  } catch (e) {
@@ -28794,6 +28882,7 @@ function logPendingCommitResolved(outcome) {
28794
28882
  writeServiceLog2("info", "mls.pending_commit.resolved.unknown");
28795
28883
  }
28796
28884
  }
28885
+ var VAULT_REF_BARE_TEXT = "[Files link]";
28797
28886
  var VOICE_MODE_GUIDANCE = "Reply immediately and conversationally, one short sentence per message; never bundle multiple points into one message.";
28798
28887
  var FAST_PATH_SPOKEN_GUIDANCE = "A one-sentence spoken acknowledgement has already been sent for this turn. Reply only with information it did not contain, in one or two short sentences; if there is nothing to add, send nothing.";
28799
28888
  var ROOM_VOICE_GUIDANCE_MENTION_ONLY = "You are in a group voice room. Reply ONLY when a message @mentions your display name. Keep replies to one short sentence. When you should not reply, send nothing.";
@@ -29288,6 +29377,8 @@ var BridgeAgent = class _BridgeAgent {
29288
29377
  * counter is incremented. Surfaced on health()/metrics().
29289
29378
  */
29290
29379
  failedAttachments = 0;
29380
+ /** Files link: vault_ref bodies refused by R1 (strict parse) or R2 (enclave binding). */
29381
+ refusedVaultRefs = 0;
29291
29382
  /**
29292
29383
  * Task 4 (self-hosted fast voice): test/deploy seam for the fast-path LLM
29293
29384
  * client. Undefined whenever `cfg.voice?.fastPath.enabled` is not true —
@@ -31349,11 +31440,12 @@ var BridgeAgent = class _BridgeAgent {
31349
31440
  * useVault.downloadFile returns `{name, bytes}` in-memory — here the
31350
31441
  * caller already supplies nodeId so we return bytes, not a re-resolved
31351
31442
  * name the bridge has no cached listing to look up). */
31352
- async vaultRead(enclaveId, vaultChannelId, nodeId) {
31443
+ async vaultRead(enclaveId, vaultChannelId, nodeId, expected) {
31353
31444
  const session = await this.getVaultSession(enclaveId, await this.resolveVaultChannelId(enclaveId, vaultChannelId));
31354
31445
  const bytes = await vaultReadImpl({
31355
31446
  enclaveId,
31356
31447
  nodeId,
31448
+ expected,
31357
31449
  deps: {
31358
31450
  exporterKekForGeneration: session.kekForGeneration,
31359
31451
  getNodeForDownload: (id) => this.vaultGetNodeForDownload(enclaveId, id),
@@ -32343,7 +32435,12 @@ var BridgeAgent = class _BridgeAgent {
32343
32435
  const encId = m.envelope.enclave_id;
32344
32436
  if (encId && !this.channelEnclaveRecorded.has(m.channelId)) {
32345
32437
  this.channelEnclaveRecorded.add(m.channelId);
32346
- this.store.recordChannelEnclave(m.channelId, encId);
32438
+ const priorEnclave = this.store.getChannelMeta(m.channelId)?.enclaveId;
32439
+ if (priorEnclave === void 0) {
32440
+ this.store.recordChannelEnclave(m.channelId, encId);
32441
+ } else if (priorEnclave !== encId) {
32442
+ writeServiceLog2("warn", "channel_enclave.envelope_mismatch");
32443
+ }
32347
32444
  }
32348
32445
  const parsed = JSON.parse(
32349
32446
  new TextDecoder().decode(m.plaintext)
@@ -32357,6 +32454,75 @@ var BridgeAgent = class _BridgeAgent {
32357
32454
  acked.push(m.queueId);
32358
32455
  continue;
32359
32456
  }
32457
+ if (parsed.type === "vault_ref") {
32458
+ const senderType = m.envelope.sender_type;
32459
+ const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : void 0;
32460
+ const chanMeta = this.freshMeta(m.channelId);
32461
+ const loopSenderIsAgent = this.loopSenderIsAgent(
32462
+ m.channelId,
32463
+ m.envelope.sender_id,
32464
+ senderIsAgent,
32465
+ false
32466
+ );
32467
+ const agentHops = this.agentHops.noteInbound(m.channelId, loopSenderIsAgent, m.messageId);
32468
+ const caption = typeof parsed.text === "string" ? parsed.text : "";
32469
+ let answerable = true;
32470
+ if (chanMeta && chanMeta.is_dm === false) {
32471
+ answerable = shouldProcessRoomMessage(
32472
+ caption,
32473
+ this.cfg.agentName ?? this.cfg.identityId,
32474
+ this.cfg.identityId,
32475
+ {
32476
+ senderIsAgent: loopSenderIsAgent,
32477
+ roomDisplayName: this.ownRoomDisplayName(),
32478
+ agentHops,
32479
+ maxAgentHops: this.maxAgentHops
32480
+ }
32481
+ );
32482
+ if (!answerable) this.filteredRoomMessages++;
32483
+ } else if (loopSenderIsAgent === true && !agentHopAllowed(agentHops, this.maxAgentHops)) {
32484
+ answerable = false;
32485
+ }
32486
+ if (!answerable) {
32487
+ if (loopSenderIsAgent === true) this.noteAgentTurnSuppressed(agentHops);
32488
+ acked.push(m.queueId);
32489
+ continue;
32490
+ }
32491
+ const ref = parseVaultRefBody(parsed);
32492
+ if (ref === null) {
32493
+ this.refusedVaultRefs++;
32494
+ writeServiceLog2("warn", "vault_ref.invalid");
32495
+ acked.push(m.queueId);
32496
+ continue;
32497
+ }
32498
+ const recordedEnclave = this.store.getChannelMeta(m.channelId)?.enclaveId;
32499
+ if (!encId || ref.enclaveId !== encId || recordedEnclave !== ref.enclaveId) {
32500
+ this.refusedVaultRefs++;
32501
+ writeServiceLog2("warn", "vault_ref.enclave_mismatch");
32502
+ acked.push(m.queueId);
32503
+ continue;
32504
+ }
32505
+ const vaultRefMessage = {
32506
+ channel_id: m.channelId,
32507
+ message_id: m.messageId,
32508
+ sender_id: m.envelope.sender_id,
32509
+ // A bare link still reads as something to a runtime that
32510
+ // ignores vault_refs.
32511
+ text: ref.text ? ref.text : VAULT_REF_BARE_TEXT,
32512
+ ts: m.receivedAt,
32513
+ authorization: this.authorizationFor(m.channelId, m.envelope.sender_id),
32514
+ sender_name: this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.name,
32515
+ sender_is_agent: senderIsAgent,
32516
+ vault_refs: [toVaultRefEntry(ref)],
32517
+ ...m.voiceMode ? {
32518
+ voice: true,
32519
+ guidance: selectVoiceGuidance(chanMeta?.is_dm, m.voiceDefaultResponder)
32520
+ } : {}
32521
+ };
32522
+ if (!await this.stageRuntimeMessage(vaultRefMessage)) continue;
32523
+ acked.push(m.queueId);
32524
+ continue;
32525
+ }
32360
32526
  if (parsed.type === "artifact_share") {
32361
32527
  try {
32362
32528
  const senderName = this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.name;
@@ -33083,6 +33249,7 @@ var BridgeAgent = class _BridgeAgent {
33083
33249
  custody_read_failing: this.provider.custodyHealth().readFailing,
33084
33250
  custody_store_unavailable: this.provider.custodyHealth().storeUnavailable,
33085
33251
  failed_attachments: this.failedAttachments,
33252
+ refused_vault_refs: this.refusedVaultRefs,
33086
33253
  fast_path_sent: this.fastPathSent,
33087
33254
  fast_path_failed: this.fastPathFailed,
33088
33255
  fast_path_skipped: this.fastPathSkipped,
@@ -33143,6 +33310,7 @@ var BridgeAgent = class _BridgeAgent {
33143
33310
  custody: this.provider.custodyHealth(),
33144
33311
  welcomeRequestsSent: this.welcomeRequestsSent,
33145
33312
  failedAttachments: this.failedAttachments,
33313
+ refusedVaultRefs: this.refusedVaultRefs,
33146
33314
  build: BRIDGE_VERSION
33147
33315
  };
33148
33316
  }
@@ -33354,6 +33522,10 @@ var BRIDGE_ERRORS = {
33354
33522
  // tell "server/network hiccup, retry" apart from "this ciphertext or key
33355
33523
  // material is not trustworthy, do not retry blindly".
33356
33524
  vault_integrity_failed: { http: 422, message: "vault content or key material failed integrity/signature verification" },
33525
+ // Files link (review 2026-10-01 R4): a pinned /v1/vault-read whose node no
33526
+ // longer holds the shared bytes or key. Terminal for that link: never fall
33527
+ // back to the current version. `detail.pin` names the refused pin.
33528
+ vault_ref_changed: { http: 422, message: "this file changed in Files after it was shared; not opened" },
33357
33529
  internal: { http: 500, message: "internal error" }
33358
33530
  };
33359
33531
  function errorBody(code, detail) {
@@ -33400,8 +33572,13 @@ function capabilities(opts) {
33400
33572
  api_version: "1",
33401
33573
  receive_modes: ["longpoll", "sse", ...opts.websocket ? ["websocket"] : []],
33402
33574
  send_types: ["text", "agent_status", "a2a"],
33403
- webhook_push: false
33575
+ webhook_push: false,
33404
33576
  // B3: A2A-style push-notification config (ciphertext/wake-only)
33577
+ // Files link (review 2026-10-01 section 6): the highest `vault_ref` body
33578
+ // version this daemon parses into BridgeMessage.vault_refs (never fetched
33579
+ // on receipt) and opens with the R4 pins on /v1/vault-read. A daemon
33580
+ // without this field skips and acks a link, so the agent never sees it.
33581
+ vault_ref: 1
33405
33582
  };
33406
33583
  }
33407
33584
  var SEND_CAP = 64 * 1024;
@@ -33443,8 +33620,17 @@ var vaultWriteSchema = z3.object({
33443
33620
  var vaultReadSchema = z3.object({
33444
33621
  enclave_id: z3.string().min(1),
33445
33622
  vault_channel_id: z3.string().min(1).optional(),
33446
- node_id: z3.string().min(1)
33447
- });
33623
+ node_id: z3.string().min(1),
33624
+ // Files link pins (review 2026-10-01 R4), copied by the runtime from a
33625
+ // BridgeMessage.vault_refs entry. Each is enforced when present; omitted,
33626
+ // the read is unchanged.
33627
+ expected_content_hash: z3.string().regex(VAULT_PIN).optional(),
33628
+ expected_dek_commit: z3.string().regex(VAULT_PIN).optional()
33629
+ }).refine(
33630
+ // Audit H1: both pins or neither. A single pin is never a weaker pinned read.
33631
+ (d) => d.expected_content_hash === void 0 === (d.expected_dek_commit === void 0),
33632
+ { message: VAULT_PINS_TOGETHER }
33633
+ );
33448
33634
  var vaultListSchema = z3.object({
33449
33635
  enclave_id: z3.string().min(1),
33450
33636
  vault_channel_id: z3.string().min(1).optional(),
@@ -33584,7 +33770,11 @@ function createHttpServer(agent, opts) {
33584
33770
  if (method === "POST" && path === "/v1/vault-read") {
33585
33771
  const parsed = vaultReadSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
33586
33772
  if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
33587
- const r = await agent.vaultRead(parsed.data.enclave_id, parsed.data.vault_channel_id, parsed.data.node_id);
33773
+ const { expected_content_hash: pinHash, expected_dek_commit: pinCommit } = parsed.data;
33774
+ const r = pinHash !== void 0 && pinCommit !== void 0 ? await agent.vaultRead(parsed.data.enclave_id, parsed.data.vault_channel_id, parsed.data.node_id, {
33775
+ contentHash: pinHash,
33776
+ dekCommit: pinCommit
33777
+ }) : await agent.vaultRead(parsed.data.enclave_id, parsed.data.vault_channel_id, parsed.data.node_id);
33588
33778
  return json(res, 200, r);
33589
33779
  }
33590
33780
  if (method === "POST" && path === "/v1/vault-list") {
@@ -33677,6 +33867,7 @@ function createHttpServer(agent, opts) {
33677
33867
  if (e instanceof GroupSupersededSendError) return jsonError(res, "group_superseded");
33678
33868
  if (e instanceof IdempotencyConflictError) return jsonError(res, "invalid_request", e.message);
33679
33869
  if (e instanceof InvalidPathError) return jsonError(res, "invalid_request", e.message);
33870
+ if (e instanceof VaultPinMismatchError) return jsonError(res, "vault_ref_changed", { pin: e.pin });
33680
33871
  if (e instanceof VaultIntegrityError) return jsonError(res, "vault_integrity_failed", e.message);
33681
33872
  if (e instanceof SyntaxError) return jsonError(res, "invalid_json");
33682
33873
  writeServiceLog2("error", "http.request_failed", {}, e);
package/dist/vault.d.ts CHANGED
@@ -6,6 +6,18 @@
6
6
  export declare class VaultIntegrityError extends Error {
7
7
  constructor(message: string, cause?: unknown);
8
8
  }
9
+ /** Which Files-link pin a pinned read refused on (review 2026-10-01 R4). */
10
+ export type VaultPin = "content_hash_server" | "ciphertext_digest" | "dek_commit";
11
+ /** A pinned read (`/v1/vault-read` with expected_*) refused because the node no
12
+ * longer holds the bytes or key that were shared: "This file changed in Files
13
+ * after it was shared. Not opened." A VaultIntegrityError, so every existing
14
+ * integrity handler still fails closed on it. */
15
+ export declare class VaultPinMismatchError extends VaultIntegrityError {
16
+ readonly pin: VaultPin;
17
+ constructor(pin: VaultPin);
18
+ }
19
+ /** The pin pair error text, shared with the HTTP boundary's refine (H1). */
20
+ export declare const VAULT_PINS_TOGETHER = "expected_content_hash and expected_dek_commit go together";
9
21
  export interface VaultWriteDeps {
10
22
  exporterKekForGeneration: (gen: number) => Uint8Array;
11
23
  currentGeneration: number;
@@ -45,10 +57,21 @@ export interface VaultReadDeps {
45
57
  }>;
46
58
  getBlob: (url: string) => Promise<Uint8Array>;
47
59
  }
60
+ /** Files-link pins for a read (review 2026-10-01 R4). Both are
61
+ * "blake2b:" + 64 hex, validated at the HTTP boundary. BOTH OR NEITHER
62
+ * (audit H1): a lone dekCommit fell back to the server's content_hash, so a
63
+ * member holding the KEK could re-encrypt new plaintext under the same DEK,
64
+ * recreate the node with the same wrap and a new hash, and be served it; a
65
+ * lone contentHash leaves the two-key swap open. */
66
+ export interface VaultReadExpectations {
67
+ contentHash: string;
68
+ dekCommit: string;
69
+ }
48
70
  export declare function vaultReadImpl(args: {
49
71
  enclaveId: string;
50
72
  nodeId: string;
51
73
  deps: VaultReadDeps;
74
+ expected?: VaultReadExpectations;
52
75
  }): Promise<Uint8Array>;
53
76
  export interface VaultListRawNode {
54
77
  node_id: string;
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const BRIDGE_VERSION = "0.25.4";
1
+ export declare const BRIDGE_VERSION = "0.26.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@securecoms/agent-bridge",
3
- "version": "0.25.4",
3
+ "version": "0.26.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22.5.0"