@agentcomms/whatsapp 0.15.2 → 0.16.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.
@@ -1,4 +1,4 @@
1
- import { C as unknown, S as union, b as strictObject, c as boolean, h as number, i as _enum, m as looseObject, n as datetime, p as literal, s as array, x as string, y as record } from "./iso-USuVU_sk.mjs";
1
+ import { C as unknown, S as union, b as strictObject, c as boolean, h as number, i as _enum, l as discriminatedUnion, m as looseObject, n as datetime, p as literal, s as array, x as string, y as record } from "./iso-USuVU_sk.mjs";
2
2
  import { access, chmod, constants, link, lstat, mkdir, open, readFile, readdir, readlink, realpath, rename, rm, stat, unlink, utimes } from "node:fs/promises";
3
3
  import path, { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { domainToASCII, fileURLToPath, pathToFileURL } from "node:url";
@@ -11,7 +11,7 @@ import { execFile, spawn } from "node:child_process";
11
11
  //#endregion
12
12
  //#region src/version.ts
13
13
  /** The package version, read from package.json at build time. */
14
- const VERSION$1 = "0.15.2";
14
+ const VERSION$1 = "0.16.0";
15
15
  //#endregion
16
16
  //#region src/caller.ts
17
17
  /** This package's name, as its manifest has it. */
@@ -28,7 +28,7 @@ const CALLER = Object.freeze({
28
28
  packageName: PACKAGE_NAME
29
29
  });
30
30
  //#endregion
31
- //#region ../core/dist/update-check-DbyonjY4.mjs
31
+ //#region ../core/dist/update-check-23Ii90fQ.mjs
32
32
  /**
33
33
  * Every failure a user or an agent can act on carries a specific, stable code from this registry, a message, an
34
34
  * optional one-line hint and structured `details`. The code decides the process exit status; agents and skills branch
@@ -144,6 +144,11 @@ const ERROR_REGISTRY = {
144
144
  retryable: true,
145
145
  summary: "the mail provider is unavailable"
146
146
  },
147
+ SOURCE_UNAVAILABLE: {
148
+ exit: EXIT_CODES.UNAVAILABLE,
149
+ retryable: false,
150
+ summary: "the selected local event source is unavailable"
151
+ },
147
152
  SECRET_STORE_UNAVAILABLE: {
148
153
  exit: EXIT_CODES.UNAVAILABLE,
149
154
  retryable: false,
@@ -174,6 +179,11 @@ const ERROR_REGISTRY = {
174
179
  retryable: false,
175
180
  summary: "the inbox was not granted this permission"
176
181
  },
182
+ WHATSAPP_VISIBILITY_SEAM_REQUIRED: {
183
+ exit: EXIT_CODES.CONFIG,
184
+ retryable: false,
185
+ summary: "the WhatsApp event visibility fence is unavailable"
186
+ },
177
187
  CONFIG: {
178
188
  exit: EXIT_CODES.CONFIG,
179
189
  retryable: false,
@@ -662,12 +672,29 @@ function dedupeBy(items, key) {
662
672
  }
663
673
  return out;
664
674
  }
675
+ const ORIGINS = ["event", "read"];
676
+ function sortedOrigins(origins) {
677
+ const known = new Set(origins);
678
+ return ORIGINS.filter((origin) => known.has(origin));
679
+ }
680
+ function originEntry(entry) {
681
+ if (entry === void 0) return ["read"];
682
+ const known = Array.isArray(entry.origins) ? entry.origins.filter((origin) => origin === "event" || origin === "read") : [];
683
+ return known.length === 0 ? ["read"] : sortedOrigins(known);
684
+ }
685
+ function isNewer(at, than) {
686
+ const left = new Date(at).getTime();
687
+ const right = new Date(than).getTime();
688
+ return Number.isFinite(left) && Number.isFinite(right) && left > right;
689
+ }
665
690
  var TaintStore = class {
666
691
  directory;
667
692
  #now;
668
- constructor(stateDir, now = () => /* @__PURE__ */ new Date()) {
693
+ #hooks;
694
+ constructor(stateDir, now = () => /* @__PURE__ */ new Date(), hooks = {}) {
669
695
  this.directory = join(stateDir, "taint");
670
696
  this.#now = now;
697
+ this.#hooks = hooks;
671
698
  }
672
699
  get #path() {
673
700
  return join(this.directory, "taint.json");
@@ -675,6 +702,9 @@ var TaintStore = class {
675
702
  get #handlesPath() {
676
703
  return join(this.directory, "handles.json");
677
704
  }
705
+ get #originsPath() {
706
+ return join(this.directory, "origins.json");
707
+ }
678
708
  async #readJson(path, empty) {
679
709
  try {
680
710
  const parsed = JSON.parse(await readFile(path, "utf8"));
@@ -700,6 +730,13 @@ var TaintStore = class {
700
730
  async #readHandles() {
701
731
  return this.#readJson(this.#handlesPath, () => ({ handles: {} }));
702
732
  }
733
+ async #readOrigins() {
734
+ return this.#readJson(this.#originsPath, () => ({
735
+ addresses: {},
736
+ domains: {},
737
+ handles: {}
738
+ }));
739
+ }
703
740
  #prune(file) {
704
741
  const cutoff = this.#now().getTime() - TAINT_WINDOW_MS;
705
742
  const keep = (map) => {
@@ -723,6 +760,35 @@ var TaintStore = class {
723
760
  handles: Object.fromEntries(fresh.slice(0, MAX_ENTRIES))
724
761
  };
725
762
  }
763
+ #mergeOrigins(origins, addresses, domains, handles, touched, at) {
764
+ const merge = (sidecar, base, observed) => {
765
+ const merged = {};
766
+ for (const [key, entry] of Object.entries(sidecar)) {
767
+ const baseEntry = base[key];
768
+ if (baseEntry === void 0) continue;
769
+ const mergedOrigins = new Set(originEntry(entry));
770
+ if (isNewer(baseEntry.at, entry.at)) mergedOrigins.add("read");
771
+ merged[key] = {
772
+ at: isNewer(baseEntry.at, entry.at) ? baseEntry.at : entry.at,
773
+ origins: sortedOrigins(mergedOrigins)
774
+ };
775
+ }
776
+ for (const [key, observedOrigins] of observed) {
777
+ const existing = merged[key];
778
+ merged[key] = {
779
+ at,
780
+ origins: sortedOrigins([...existing?.origins ?? [], ...observedOrigins])
781
+ };
782
+ }
783
+ return merged;
784
+ };
785
+ return {
786
+ ...origins,
787
+ addresses: merge(origins.addresses, addresses, touched.addresses),
788
+ domains: merge(origins.domains, domains, touched.domains),
789
+ handles: merge(origins.handles, handles, touched.handles)
790
+ };
791
+ }
726
792
  /**
727
793
  * Records observations. Throws if it cannot — callers must fail the read rather than return content whose taint
728
794
  * was not recorded.
@@ -741,7 +807,17 @@ var TaintStore = class {
741
807
  })).filter((o) => o.address.includes("@") && !own.has(o.address) && !internal.has(domainOf(o.address) ?? "")).sort((a, b) => a.source === b.source ? 0 : a.source === "header" ? -1 : 1), (o) => o.address).slice(0, MAX_PER_MESSAGE);
742
808
  if (kept.length === 0 && keptHandles.length === 0) return;
743
809
  const at = this.#now().toISOString();
744
- const touch = (map, key, o) => {
810
+ const touched = {
811
+ addresses: /* @__PURE__ */ new Map(),
812
+ domains: /* @__PURE__ */ new Map(),
813
+ handles: /* @__PURE__ */ new Map()
814
+ };
815
+ const rememberOrigin = (map, key, origin) => {
816
+ const origins = touched[map].get(key) ?? /* @__PURE__ */ new Set();
817
+ origins.add(origin ?? "read");
818
+ touched[map].set(key, origins);
819
+ };
820
+ const touch = (map, key, o, originMap) => {
745
821
  const existing = map[key];
746
822
  const inboxIds = [.../* @__PURE__ */ new Set([...existing?.inboxIds ?? [], o.inboxId])];
747
823
  const source = existing?.source === "header" ? "header" : o.source;
@@ -750,27 +826,38 @@ var TaintStore = class {
750
826
  source,
751
827
  inboxIds
752
828
  };
829
+ rememberOrigin(originMap, key, o.origin);
753
830
  };
754
- if (kept.length > 0) {
755
- const path = this.#path;
756
- await withFileLock(`${path}.lock`, async () => {
757
- const file = this.#prune(await this.#read());
758
- for (const o of kept) {
759
- touch(file.addresses, o.address, o);
760
- const domain = domainOf(o.address);
761
- if (domain && !PUBLIC_MAILBOX_DOMAINS.has(domain)) touch(file.domains, domain, o);
762
- }
763
- await writeFileAtomic(path, JSON.stringify(file));
764
- });
765
- }
766
- if (keptHandles.length > 0) {
767
- const path = this.#handlesPath;
768
- await withFileLock(`${path}.lock`, async () => {
769
- const file = this.#pruneHandles(await this.#readHandles());
770
- for (const o of keptHandles) touch(file.handles, o.key, o);
771
- await writeFileAtomic(path, JSON.stringify(file));
831
+ const commit = async () => {
832
+ const taint = this.#prune(await this.#read());
833
+ const handles = this.#pruneHandles(await this.#readHandles());
834
+ if (kept.length > 0) for (const o of kept) {
835
+ touch(taint.addresses, o.address, o, "addresses");
836
+ const domain = domainOf(o.address);
837
+ if (domain && !PUBLIC_MAILBOX_DOMAINS.has(domain)) touch(taint.domains, domain, o, "domains");
838
+ }
839
+ if (keptHandles.length > 0) for (const o of keptHandles) touch(handles.handles, o.key, o, "handles");
840
+ const finalTaint = this.#prune(taint);
841
+ const finalHandles = this.#pruneHandles(handles);
842
+ const origins = this.#mergeOrigins(await this.#readOrigins(), finalTaint.addresses, finalTaint.domains, finalHandles.handles, touched, at);
843
+ await writeFileAtomic(this.#originsPath, JSON.stringify(origins));
844
+ await this.#hooks.afterOriginsCommit?.();
845
+ if (kept.length > 0) await writeFileAtomic(this.#path, JSON.stringify(finalTaint));
846
+ if (keptHandles.length > 0) await writeFileAtomic(this.#handlesPath, JSON.stringify(finalHandles));
847
+ };
848
+ const withBaseLocks = async () => {
849
+ await withFileLock(`${this.#path}.lock`, async () => {
850
+ this.#hooks.onLockAcquired?.("taint");
851
+ await withFileLock(`${this.#handlesPath}.lock`, async () => {
852
+ this.#hooks.onLockAcquired?.("handles");
853
+ await commit();
854
+ });
772
855
  });
773
- }
856
+ };
857
+ await withFileLock(`${this.#originsPath}.lock`, async () => {
858
+ this.#hooks.onLockAcquired?.("origins");
859
+ await withBaseLocks();
860
+ });
774
861
  }
775
862
  /**
776
863
  * Whether an address, or its (non-public) domain, was seen in email content in the window — from any inbox — and the
@@ -778,20 +865,22 @@ var TaintStore = class {
778
865
  */
779
866
  async check(address) {
780
867
  const file = this.#prune(await this.#read());
868
+ const origins = await this.#readOrigins();
781
869
  const canonical = canonicalAddress(address);
782
870
  const domain = domainOf(canonical);
783
871
  const exact = Object.hasOwn(file.addresses, canonical) ? file.addresses[canonical] : void 0;
784
872
  const byDomain = domain !== null && !PUBLIC_MAILBOX_DOMAINS.has(domain) && Object.hasOwn(file.domains, domain) ? file.domains[domain] : void 0;
785
- const aggregate = (entry) => ({
873
+ const aggregate = (entry, origin) => ({
786
874
  at: entry.at,
787
875
  source: entry.source,
788
- inboxIds: [...entry.inboxIds]
876
+ inboxIds: [...entry.inboxIds],
877
+ origins: sortedOrigins([...originEntry(origin), ...origin !== void 0 && isNewer(entry.at, origin.at) ? ["read"] : []])
789
878
  });
790
879
  return {
791
880
  address: exact !== void 0,
792
881
  domain: byDomain !== void 0,
793
- ...exact === void 0 ? {} : { addressSeen: aggregate(exact) },
794
- ...byDomain === void 0 ? {} : { domainSeen: aggregate(byDomain) }
882
+ ...exact === void 0 ? {} : { addressSeen: aggregate(exact, origins.addresses[canonical]) },
883
+ ...byDomain === void 0 ? {} : { domainSeen: aggregate(byDomain, origins.domains[domain ?? ""]) }
795
884
  };
796
885
  }
797
886
  /**
@@ -804,6 +893,15 @@ var TaintStore = class {
804
893
  const file = this.#pruneHandles(await this.#readHandles());
805
894
  return canonicalHandle(handle) in file.handles;
806
895
  }
896
+ /** The merged provenance of a retained handle, or no origins when it is not in the taint window. */
897
+ async checkHandleOrigins(handle) {
898
+ const file = this.#pruneHandles(await this.#readHandles());
899
+ const key = canonicalHandle(handle);
900
+ const entry = file.handles[key];
901
+ if (entry === void 0) return [];
902
+ const sidecar = (await this.#readOrigins()).handles[key];
903
+ return sortedOrigins([...originEntry(sidecar), ...sidecar !== void 0 && isNewer(entry.at, sidecar.at) ? ["read"] : []]);
904
+ }
807
905
  };
808
906
  /** Every first-party channel, the core first: what core knows about each without installing it. */
809
907
  const CHANNEL_SNAPSHOT = [
@@ -883,7 +981,19 @@ const CHANNEL_SNAPSHOT = [
883
981
  prefix: "gmail-",
884
982
  contract: "skills/_shared/contract-gmail.md"
885
983
  },
886
- approvalGrouping: "draft"
984
+ approvalGrouping: "draft",
985
+ events: {
986
+ types: [
987
+ "gmail.message.received",
988
+ "gmail.message.sent",
989
+ "gmail.message.labelled"
990
+ ],
991
+ minimumIntervalMs: 6e4,
992
+ access: {
993
+ kind: "oauth-user",
994
+ requiredScopes: ["https://www.googleapis.com/auth/gmail.readonly"]
995
+ }
996
+ }
887
997
  }
888
998
  },
889
999
  {
@@ -923,7 +1033,12 @@ const CHANNEL_SNAPSHOT = [
923
1033
  prefix: "resend-",
924
1034
  contract: "skills/_shared/contract-resend.md"
925
1035
  },
926
- approvalGrouping: "draft"
1036
+ approvalGrouping: "draft",
1037
+ events: {
1038
+ types: ["resend.email.received", "resend.email.status_changed"],
1039
+ minimumIntervalMs: 6e4,
1040
+ access: { kind: "resend-full-access" }
1041
+ }
927
1042
  }
928
1043
  },
929
1044
  {
@@ -963,7 +1078,20 @@ const CHANNEL_SNAPSHOT = [
963
1078
  prefix: "slack-",
964
1079
  contract: "skills/_shared/contract-slack.md"
965
1080
  },
966
- approvalGrouping: "draft-revision-digest"
1081
+ approvalGrouping: "draft-revision-digest",
1082
+ events: {
1083
+ types: ["slack.message.posted"],
1084
+ minimumIntervalMs: 6e4,
1085
+ access: {
1086
+ kind: "oauth-user",
1087
+ requiredScopes: [
1088
+ "channels:history",
1089
+ "groups:history",
1090
+ "im:history",
1091
+ "mpim:history"
1092
+ ]
1093
+ }
1094
+ }
967
1095
  }
968
1096
  },
969
1097
  {
@@ -1002,10 +1130,76 @@ const CHANNEL_SNAPSHOT = [
1002
1130
  skills: {
1003
1131
  prefix: "whatsapp-",
1004
1132
  contract: "skills/_shared/contract-whatsapp.md"
1133
+ },
1134
+ events: {
1135
+ types: ["whatsapp.message.received"],
1136
+ minimumIntervalMs: 6e4,
1137
+ access: { kind: "local-store" }
1005
1138
  }
1006
1139
  }
1007
1140
  }
1008
1141
  ];
1142
+ const DISCLOSURE_VERSION_KINDS = [
1143
+ "rule",
1144
+ "target",
1145
+ "subscriber",
1146
+ "judge",
1147
+ "judge-budget",
1148
+ "judge-kind"
1149
+ ];
1150
+ const DISCLOSURE_ACTIVATION_KINDS = [
1151
+ "rule",
1152
+ "judge-budget",
1153
+ "judge-kind",
1154
+ "enable-all"
1155
+ ];
1156
+ const HEX64$1 = /^[0-9a-f]{64}$/;
1157
+ function disclosureFailure() {
1158
+ throw new CommsError("BAD_DATA", "a disclosure binding is not in its canonical activation shape");
1159
+ }
1160
+ function compareDisclosureVersions(left, right) {
1161
+ const compareText = (one, two) => one < two ? -1 : one > two ? 1 : 0;
1162
+ return compareText(left.kind, right.kind) || compareText(left.id, right.id) || left.version - right.version;
1163
+ }
1164
+ /**
1165
+ * Validates and copies a disclosure binding without reordering it. A caller must provide the
1166
+ * daemon-derived canonical list, already sorted by (kind, id, version), so a different document
1167
+ * cannot become authorised by a convenient normalisation here.
1168
+ */
1169
+ function canonicalDisclosureBinding(binding) {
1170
+ if (!HEX64$1.test(binding.digest) || typeof binding.activationIntentId !== "string" || binding.activationIntentId.length === 0 || !DISCLOSURE_ACTIVATION_KINDS.includes(binding.activationKind) || !Array.isArray(binding.versions)) return disclosureFailure();
1171
+ const versions = binding.versions.map((entry) => {
1172
+ if (!DISCLOSURE_VERSION_KINDS.includes(entry.kind) || typeof entry.id !== "string" || entry.id.length === 0 || !Number.isSafeInteger(entry.version) || entry.version < 1) return disclosureFailure();
1173
+ return {
1174
+ kind: entry.kind,
1175
+ id: entry.id,
1176
+ version: entry.version
1177
+ };
1178
+ });
1179
+ for (let index = 1; index < versions.length; index += 1) if (compareDisclosureVersions(versions[index - 1], versions[index]) >= 0) return disclosureFailure();
1180
+ const kinds = new Set(versions.map((entry) => entry.kind));
1181
+ if (binding.activationKind === "rule") {
1182
+ if (!kinds.has("rule") || kinds.has("judge-budget") || kinds.has("judge-kind")) return disclosureFailure();
1183
+ } else if (binding.activationKind === "judge-budget") {
1184
+ if (versions.length !== 1 || versions[0]?.kind !== "judge-budget") return disclosureFailure();
1185
+ } else if (binding.activationKind === "judge-kind") {
1186
+ if (versions.length !== 1 || versions[0]?.kind !== "judge-kind") return disclosureFailure();
1187
+ } else if (versions.some((entry) => entry.kind !== "rule")) return disclosureFailure();
1188
+ return {
1189
+ digest: binding.digest,
1190
+ activationIntentId: binding.activationIntentId,
1191
+ activationKind: binding.activationKind,
1192
+ versions
1193
+ };
1194
+ }
1195
+ /** Exact canonical equality for each re-check at approval and claim. */
1196
+ function sameDisclosureBinding(left, right) {
1197
+ try {
1198
+ return canonicalJson$1(canonicalDisclosureBinding(left)) === canonicalJson$1(canonicalDisclosureBinding(right));
1199
+ } catch {
1200
+ return false;
1201
+ }
1202
+ }
1009
1203
  /** The lifetime profile, in milliseconds: pending on each route, approved, and a download's question. */
1010
1204
  const APPROVAL_LIFETIMES = Object.freeze({
1011
1205
  chat: 6e5,
@@ -1081,18 +1275,25 @@ function listedEntry(file) {
1081
1275
  * folders it offered, and the listing exactly as stored, left out when the record has none.
1082
1276
  */
1083
1277
  function bindingObjectOf(record) {
1084
- const identity = identityOf(record);
1085
- if (record.kind === "download") {
1086
- const download = record.download;
1278
+ if (record.kind === "disclosure" && "disclosure" in record && record.disclosure !== void 0) return {
1279
+ v: 2,
1280
+ kind: "disclosure",
1281
+ approvalId: record.approvalId,
1282
+ disclosure: canonicalDisclosureBinding(record.disclosure)
1283
+ };
1284
+ const ordinary = record;
1285
+ const identity = identityOf(ordinary);
1286
+ if (ordinary.kind === "download") {
1287
+ const download = ordinary.download;
1087
1288
  if (download === void 0) throw new CommsError("BAD_DATA", "a download’s approval does not hold the question it was asked for");
1088
1289
  return {
1089
1290
  v: 2,
1090
1291
  kind: "download",
1091
- contentDigest: record.contentDigest,
1292
+ contentDigest: ordinary.contentDigest,
1092
1293
  profile: {
1093
- pendingMs: record.pendingMs,
1094
- policy: record.policy,
1095
- requiredPolicy: record.requiredPolicy
1294
+ pendingMs: ordinary.pendingMs,
1295
+ policy: ordinary.policy,
1296
+ requiredPolicy: ordinary.requiredPolicy
1096
1297
  },
1097
1298
  identity,
1098
1299
  offered: {
@@ -1104,11 +1305,11 @@ function bindingObjectOf(record) {
1104
1305
  }
1105
1306
  return {
1106
1307
  v: 2,
1107
- kind: record.kind,
1108
- contentDigest: record.contentDigest,
1109
- route: record.route,
1110
- pendingMs: record.pendingMs,
1111
- approvedMs: record.approvedMs,
1308
+ kind: ordinary.kind,
1309
+ contentDigest: ordinary.contentDigest,
1310
+ route: ordinary.route,
1311
+ pendingMs: ordinary.pendingMs,
1312
+ approvedMs: ordinary.approvedMs,
1112
1313
  identity
1113
1314
  };
1114
1315
  }
@@ -5678,7 +5879,11 @@ const NO_CONFIGURATION = "this approval store was opened without a configuration
5678
5879
  const sentAtWords = (usedAt) => `sent at ${usedAt}`;
5679
5880
  /** The refusal prefix and detail key of each kind: a send sends nothing, a change changes nothing, a question saves nothing. */
5680
5881
  function refusalOf(kind) {
5681
- const [prefix, idKey, hint] = kind === "change" ? [
5882
+ const [prefix, idKey, hint] = kind === "disclosure" ? [
5883
+ "nothing was disclosed",
5884
+ "approvalId",
5885
+ "Prepare a new standing disclosure authorisation and show its exact activation preview to the person."
5886
+ ] : kind === "change" ? [
5682
5887
  "nothing was changed",
5683
5888
  "approvalId",
5684
5889
  "Prepare the change again and show the new preview to the user."
@@ -5705,7 +5910,7 @@ function refusalOf(kind) {
5705
5910
  * or one a pinned surface may not see — byte for byte the same, with `approval: null`, and nothing of any record in it.
5706
5911
  */
5707
5912
  function approvalNotFound(approvalId, kind) {
5708
- return new CommsError("NOT_FOUND", `${kind === "change" ? "nothing was changed" : kind === "download" ? "nothing was saved" : "nothing was sent"}: no approval ${approvalId}`, {
5913
+ return new CommsError("NOT_FOUND", `${kind === "disclosure" ? "nothing was disclosed" : kind === "change" ? "nothing was changed" : kind === "download" ? "nothing was saved" : "nothing was sent"}: no approval ${approvalId}`, {
5709
5914
  hint: "Check the id: an approval is only found by the surface, the kind and the owner it was prepared for.",
5710
5915
  details: { approval: null }
5711
5916
  });
@@ -5737,6 +5942,24 @@ function objectOf(record, state, claimable, ownerRemoved, reason) {
5737
5942
  ...optional("expiredAt", record.expiredAt)
5738
5943
  };
5739
5944
  }
5945
+ /** Standing disclosure has no channel owner, draft, policy or sender-controlled field to classify. */
5946
+ function disclosureObjectOf(record, claimable, reason) {
5947
+ return {
5948
+ id: record.approvalId,
5949
+ kind: "disclosure",
5950
+ channel: null,
5951
+ state: record.state,
5952
+ claimable,
5953
+ ...reason === void 0 ? {} : { reason },
5954
+ createdAt: record.createdAt,
5955
+ expiresAt: record.expiresAt,
5956
+ ...record.approvedAt === void 0 ? {} : { approvedAt: record.approvedAt },
5957
+ ...record.usableUntil === void 0 ? {} : { usableUntil: record.usableUntil },
5958
+ ...record.usedAt === void 0 ? {} : { usedAt: record.usedAt },
5959
+ ...record.revokedAt === void 0 ? {} : { revokedAt: record.revokedAt },
5960
+ ...record.expiredAt === void 0 ? {} : { expiredAt: record.expiredAt }
5961
+ };
5962
+ }
5740
5963
  Object.freeze([
5741
5964
  "expect.to",
5742
5965
  "expect.cc",
@@ -5758,6 +5981,7 @@ function downloadExpiredWords(answered) {
5758
5981
  * nothing claimable. Pure: what decides is the store's locked classification, and this only says where it stands.
5759
5982
  */
5760
5983
  function approvalObjectOf(record, live, now) {
5984
+ if (record.kind === "disclosure") return classifyDisclosure(record).approval;
5761
5985
  return classifyV2(record, {
5762
5986
  action: "inspect",
5763
5987
  live,
@@ -5837,7 +6061,7 @@ function approvalOutcome(stored, context) {
5837
6061
  approval: {
5838
6062
  id,
5839
6063
  kind: safe?.kind ?? null,
5840
- channel: safe?.channel ?? null,
6064
+ channel: safe?.kind === "disclosure" ? null : safe?.channel ?? null,
5841
6065
  state: "corrupt",
5842
6066
  claimable: false,
5843
6067
  reason
@@ -5848,6 +6072,7 @@ function approvalOutcome(stored, context) {
5848
6072
  return classifyV2(stored.record, context);
5849
6073
  }
5850
6074
  function classifyV2(read, context) {
6075
+ if (read.kind === "disclosure") return classifyDisclosure(read);
5851
6076
  const { live, action } = context;
5852
6077
  const at = context.now.toISOString();
5853
6078
  const ownerRemoved = read.ownerScope === "owner" && live?.owner === "removed";
@@ -5896,12 +6121,42 @@ function classifyV2(read, context) {
5896
6121
  ...error === void 0 ? {} : { error }
5897
6122
  };
5898
6123
  }
6124
+ /** The isolated disclosure state machine never follows a config policy or a channel-owner fence. */
6125
+ function classifyDisclosure(record) {
6126
+ const claimable = record.state === "approved";
6127
+ const approval = disclosureObjectOf(record, claimable, record.reason);
6128
+ let error;
6129
+ switch (record.state) {
6130
+ case "pending":
6131
+ case "approved": break;
6132
+ case "used":
6133
+ error = refusalOf("disclosure")("APPROVAL_VOID", `the standing disclosure approval was used already at ${record.usedAt}`, record, approval);
6134
+ break;
6135
+ case "expired":
6136
+ error = refusalOf("disclosure")("APPROVAL_EXPIRED", "the standing disclosure approval expired unused", record, approval);
6137
+ break;
6138
+ case "revoked":
6139
+ error = refusalOf("disclosure")("APPROVAL_VOID", `the standing disclosure approval was voided (${record.reason ?? "revoked"})`, record, approval);
6140
+ break;
6141
+ default: error = refusalOf("disclosure")("APPROVAL_VOID", "the standing disclosure approval is in an invalid state", record, approval);
6142
+ }
6143
+ return {
6144
+ state: record.state,
6145
+ claimable,
6146
+ ...record.reason === void 0 ? {} : { reason: record.reason },
6147
+ record,
6148
+ revokes: false,
6149
+ approval,
6150
+ ...error === void 0 ? {} : { error }
6151
+ };
6152
+ }
5899
6153
  /** Whether an active record can be claimed under `live` (D2, `claimable`). */
5900
6154
  function isClaimable(record, live) {
5901
6155
  switch (record.kind) {
5902
6156
  case "send": return record.state === "approved" || record.route === "chat" && live.sendPolicy === "chat";
5903
6157
  case "change": return record.state === "approved" || record.route === "chat" && live.changePolicy === "chat";
5904
6158
  case "download": return downloadClaimable(record, live.changePolicy);
6159
+ case "disclosure": return record.state === "approved";
5905
6160
  }
5906
6161
  }
5907
6162
  /**
@@ -5959,6 +6214,7 @@ function errorOf(record, approval, context) {
5959
6214
  });
5960
6215
  }
5961
6216
  case "used":
6217
+ if (record.kind === "disclosure") return refuse("APPROVAL_VOID", `the standing disclosure approval was used already at ${record.usedAt}`, record, approval);
5962
6218
  if (download) return refuse("APPROVAL_VOID", "the question was answered already, and an answer is used once", record, approval);
5963
6219
  if (record.kind === "change") return refuse("APPROVAL_VOID", `the approved change was already claimed at ${record.usedAt}`, record, approval);
5964
6220
  return refuse("APPROVAL_VOID", `the approval was used already: it was ${context.usedSaid(String(record.usedAt))}, message id ${record.sentMessageId}`, record, approval, "Prepare a new send only for a new message.");
@@ -6169,10 +6425,24 @@ function refuseDownload(code, reason, record, hint) {
6169
6425
  } : {}
6170
6426
  });
6171
6427
  }
6428
+ /** The disclosure path has no send, draft or provider outcome: its refusal must never imply one. */
6429
+ function refuseDisclosure(code, reason, record, hint) {
6430
+ return new CommsError(code, `nothing was disclosed: ${reason}`, {
6431
+ hint: hint ?? "Prepare a new standing disclosure authorisation and show its exact activation preview to the person.",
6432
+ details: record ? {
6433
+ approvalId: record.approvalId,
6434
+ state: record.state
6435
+ } : {}
6436
+ });
6437
+ }
6172
6438
  /** The refusal in the words of the record's own kind. */
6173
6439
  function refusalFor(record) {
6174
- const kind = approvalKind(record);
6175
- return kind === "change" ? refuseChange : kind === "download" ? refuseDownload : refuse;
6440
+ switch (approvalKind(record)) {
6441
+ case "send": return refuse;
6442
+ case "change": return refuseChange;
6443
+ case "download": return refuseDownload;
6444
+ case "disclosure": return refuseDisclosure;
6445
+ }
6176
6446
  }
6177
6447
  /**
6178
6448
  * The refusal of a record prepared under another digest version, in the words of its own kind: the one every locked
@@ -6189,23 +6459,40 @@ function otherVersionRefusal(record) {
6189
6459
  * nothing is sent by answering one. The record is as it was, and the hint says so.
6190
6460
  */
6191
6461
  function cancelledClaim(record) {
6192
- const kind = approvalKind(record);
6193
- if (kind === "download") return new CommsError("USAGE", "cancelled: nothing was saved", {
6194
- hint: "The question was not used: the same call, made again with the same answer, can still use it until it expires.",
6195
- details: {
6196
- choiceId: record.approvalId,
6197
- state: record.state,
6198
- reason: "cancelled"
6199
- }
6200
- });
6201
- return new CommsError("USAGE", `cancelled: ${kind === "change" ? "nothing was changed" : "nothing was sent"}`, {
6202
- hint: "The approval was not used: the same call, made again, can still use it until it expires.",
6203
- details: {
6204
- approvalId: record.approvalId,
6205
- state: record.state,
6206
- reason: "cancelled"
6207
- }
6208
- });
6462
+ switch (approvalKind(record)) {
6463
+ case "send": return new CommsError("USAGE", "cancelled: nothing was sent", {
6464
+ hint: "The approval was not used: the same call, made again, can still use it until it expires.",
6465
+ details: {
6466
+ approvalId: record.approvalId,
6467
+ state: record.state,
6468
+ reason: "cancelled"
6469
+ }
6470
+ });
6471
+ case "change": return new CommsError("USAGE", "cancelled: nothing was changed", {
6472
+ hint: "The approval was not used: the same call, made again, can still use it until it expires.",
6473
+ details: {
6474
+ approvalId: record.approvalId,
6475
+ state: record.state,
6476
+ reason: "cancelled"
6477
+ }
6478
+ });
6479
+ case "download": return new CommsError("USAGE", "cancelled: nothing was saved", {
6480
+ hint: "The question was not used: the same call, made again with the same answer, can still use it until it expires.",
6481
+ details: {
6482
+ choiceId: record.approvalId,
6483
+ state: record.state,
6484
+ reason: "cancelled"
6485
+ }
6486
+ });
6487
+ case "disclosure": return new CommsError("USAGE", "cancelled: nothing was disclosed", {
6488
+ hint: "The standing disclosure approval was not used: it can still be completed until it expires.",
6489
+ details: {
6490
+ approvalId: record.approvalId,
6491
+ state: record.state,
6492
+ reason: "cancelled"
6493
+ }
6494
+ });
6495
+ }
6209
6496
  }
6210
6497
  /** The refusal of a renewal or an outcome from a call that does not hold the send's claim. It names no token. */
6211
6498
  function notTheClaim(approvalId) {
@@ -6232,7 +6519,9 @@ function approvePendingHint(handoffs, approvalId, platform) {
6232
6519
  function matchesExpectation(found, expect) {
6233
6520
  const kind = kindOf$1(found);
6234
6521
  const owner = ownerOf(found);
6235
- if (kind === null || owner === null) return expect.owner === void 0;
6522
+ if (kind === null) return expect.owner === void 0;
6523
+ if (kind === "disclosure") return (expect.kind === void 0 || expect.kind === "disclosure") && expect.channel === void 0 && expect.owner === void 0;
6524
+ if (owner === null) return expect.owner === void 0;
6236
6525
  if (expect.kind !== void 0 && kind !== expect.kind) return false;
6237
6526
  if (expect.channel !== void 0 && channelOf(found) !== expect.channel) return false;
6238
6527
  return expect.owner === void 0 || owner === expect.owner;
@@ -6357,6 +6646,18 @@ var ApprovalStore = class {
6357
6646
  * profile checked first, so nothing is written that names a channel this release does not know.
6358
6647
  */
6359
6648
  #bound(record) {
6649
+ if (record.kind === "disclosure") {
6650
+ const disclosure = canonicalDisclosureBinding(record.disclosure);
6651
+ return {
6652
+ ...record,
6653
+ disclosure,
6654
+ bindingDigest: bindingDigestOf({
6655
+ approvalId: record.approvalId,
6656
+ kind: "disclosure",
6657
+ disclosure
6658
+ })
6659
+ };
6660
+ }
6360
6661
  requireKnownChannel(record.channel);
6361
6662
  return {
6362
6663
  ...record,
@@ -6404,6 +6705,28 @@ var ApprovalStore = class {
6404
6705
  await this.#write(record);
6405
6706
  return record;
6406
6707
  }
6708
+ /** Creates only a pending standing disclosure approval from a daemon-derived canonical binding. */
6709
+ async createDisclosure(binding) {
6710
+ await this.ensurePruned();
6711
+ const disclosure = canonicalDisclosureBinding(binding);
6712
+ const now = this.#now();
6713
+ const pendingMs = APPROVAL_LIFETIMES.confirm;
6714
+ const record = this.#bound({
6715
+ approvalId: newApprovalId(),
6716
+ kind: "disclosure",
6717
+ digestVersion: 2,
6718
+ pendingMs,
6719
+ approvedMs: APPROVAL_LIFETIMES.approved,
6720
+ disclosure,
6721
+ challengeAttempts: 0,
6722
+ state: "pending",
6723
+ createdAt: now.toISOString(),
6724
+ expiresAt: new Date(now.getTime() + pendingMs).toISOString(),
6725
+ updatedAt: now.toISOString()
6726
+ });
6727
+ await this.#write(record);
6728
+ return record;
6729
+ }
6407
6730
  /**
6408
6731
  * One approval, in whichever of its four forms it is (`StoredApproval`), read against one configuration snapshot —
6409
6732
  * or null when there is none. Nothing is written.
@@ -6554,17 +6877,33 @@ var ApprovalStore = class {
6554
6877
  * approval can be used as it is.
6555
6878
  */
6556
6879
  #alreadyApproved(record, outcome) {
6557
- return new CommsError("USAGE", `${record.kind === "change" ? "nothing was changed" : "nothing was sent"}: it is approved already, and a person approves it once`, {
6558
- hint: "Nothing more is needed from the person: try the same call again with the same approval.",
6559
- details: {
6560
- approvalId: record.approvalId,
6561
- state: record.state,
6562
- approval: outcome.approval
6563
- }
6564
- });
6880
+ const detail = {
6881
+ approvalId: record.approvalId,
6882
+ state: record.state,
6883
+ approval: outcome.approval
6884
+ };
6885
+ switch (record.kind) {
6886
+ case "send": return new CommsError("USAGE", "nothing was sent: it is approved already, and a person approves it once", {
6887
+ hint: "Nothing more is needed from the person: try the same call again with the same approval.",
6888
+ details: detail
6889
+ });
6890
+ case "change": return new CommsError("USAGE", "nothing was changed: it is approved already, and a person approves it once", {
6891
+ hint: "Nothing more is needed from the person: try the same call again with the same approval.",
6892
+ details: detail
6893
+ });
6894
+ case "download": return new CommsError("USAGE", "nothing was saved: it is approved already, and a person approves it once", {
6895
+ hint: "Nothing more is needed from the person: try the same call again with the same approval.",
6896
+ details: detail
6897
+ });
6898
+ case "disclosure": return new CommsError("USAGE", "nothing was disclosed: it is approved already, and a person approves it once", {
6899
+ hint: "Nothing more is needed from the person: complete the same activation with the same approval.",
6900
+ details: detail
6901
+ });
6902
+ }
6565
6903
  }
6566
6904
  /** Issues a new challenge to show a human; only its hash is kept. */
6567
6905
  async issueChallenge(approvalId, kind = "send", _platform = process.platform, options = {}) {
6906
+ if (kind === "disclosure") throw new CommsError("APPROVAL_REQUIRED", "nothing was disclosed: a standing disclosure challenge is only issued to the trusted terminal or app route");
6568
6907
  const challenge = newChallenge();
6569
6908
  await this.#transition(approvalId, { kind }, "approve", (current, outcome) => {
6570
6909
  if (outcome.error) throw outcome.error;
@@ -6576,6 +6915,139 @@ var ApprovalStore = class {
6576
6915
  }, { usedSaid: options.usedSaid });
6577
6916
  return challenge;
6578
6917
  }
6918
+ /** Issues the typed challenge that a terminal or the trusted desktop app must answer for disclosure. */
6919
+ async issueDisclosureChallenge(approvalId) {
6920
+ const challenge = newChallenge();
6921
+ await this.#transition(approvalId, { kind: "disclosure" }, "approve", (current, outcome) => {
6922
+ if (outcome.error) throw outcome.error;
6923
+ if (current.state !== "pending") throw this.#alreadyApproved(current, outcome);
6924
+ return {
6925
+ ...current,
6926
+ challengeHash: hashChallenge(challenge)
6927
+ };
6928
+ });
6929
+ return challenge;
6930
+ }
6931
+ /**
6932
+ * Approves one exact standing disclosure authority. MCP/chat forms are never an approval channel here: `app` means
6933
+ * the desktop application's trusted typed-challenge bridge, not a client form.
6934
+ */
6935
+ async approveDisclosure(approvalId, liveBinding, answer, via) {
6936
+ let failure = null;
6937
+ const done = await this.#transition(approvalId, { kind: "disclosure" }, "approve", (current, outcome) => {
6938
+ if (outcome.error) throw outcome.error;
6939
+ if (current.state !== "pending") throw this.#alreadyApproved(current, outcome);
6940
+ if (via !== "terminal" && via !== "app") throw new CommsError("APPROVAL_REQUIRED", "nothing was disclosed: approval is only at the terminal or the trusted app", { details: {
6941
+ approvalId: current.approvalId,
6942
+ state: current.state
6943
+ } });
6944
+ if (!current.challengeHash) throw new CommsError("APPROVAL_REQUIRED", "nothing was disclosed: no challenge was issued for this approval", { details: {
6945
+ approvalId: current.approvalId,
6946
+ state: current.state
6947
+ } });
6948
+ const at = this.#now();
6949
+ const binding = current.disclosure;
6950
+ if (!sameDisclosureBinding(binding, liveBinding)) {
6951
+ failure = {
6952
+ code: "APPROVAL_VOID",
6953
+ reason: "the disclosure binding changed after it was prepared"
6954
+ };
6955
+ return {
6956
+ ...current,
6957
+ state: "revoked",
6958
+ reason: failure.reason,
6959
+ revokedAt: at.toISOString(),
6960
+ challengeHash: void 0
6961
+ };
6962
+ }
6963
+ if (!challengeMatches(answer, current.challengeHash)) {
6964
+ const attempts = current.challengeAttempts + 1;
6965
+ if (attempts >= 3) {
6966
+ failure = {
6967
+ code: "APPROVAL_VOID",
6968
+ reason: "too many wrong answers to the challenge"
6969
+ };
6970
+ return {
6971
+ ...current,
6972
+ challengeAttempts: attempts,
6973
+ state: "revoked",
6974
+ reason: failure.reason,
6975
+ revokedAt: at.toISOString()
6976
+ };
6977
+ }
6978
+ failure = {
6979
+ code: "APPROVAL_REQUIRED",
6980
+ reason: "the challenge did not match"
6981
+ };
6982
+ return {
6983
+ ...current,
6984
+ challengeAttempts: attempts
6985
+ };
6986
+ }
6987
+ return {
6988
+ ...current,
6989
+ state: "approved",
6990
+ approvedVia: via,
6991
+ approvedAt: at.toISOString(),
6992
+ usableUntil: new Date(at.getTime() + APPROVAL_LIFETIMES.approved).toISOString(),
6993
+ approvedDigest: binding.digest,
6994
+ challengeHash: void 0
6995
+ };
6996
+ });
6997
+ const refused = failure;
6998
+ if (refused !== null) throw refusedAfter(refusalFor(done.record)(refused.code, refused.reason, done.record), done);
6999
+ return done.record;
7000
+ }
7001
+ /**
7002
+ * Claims an approved disclosure once, persisting the exact `usedAt` that recovery must subsequently reuse.
7003
+ * A specialised caller may impose a shorter deadline; it is evaluated while this record's transition lock is held,
7004
+ * so a check made before waiting on that lock cannot accidentally consume the approval after it has expired.
7005
+ */
7006
+ async claimForDisclosure(approvalId, liveBinding, options = {}) {
7007
+ if (options.deadlineMs !== void 0 && (!Number.isSafeInteger(options.deadlineMs) || options.deadlineMs <= 0)) throw new CommsError("BAD_DATA", "the disclosure claim deadline is not a readable instant");
7008
+ let failure = null;
7009
+ const done = await this.#transition(approvalId, { kind: "disclosure" }, "claim", (current, outcome) => {
7010
+ if (outcome.error) throw outcome.error;
7011
+ const now = this.#now();
7012
+ if (options.deadlineMs !== void 0 && now.getTime() >= options.deadlineMs) throw new CommsError("APPROVAL_VOID", "nothing was disclosed: the disclosure challenge expired before claim", { details: {
7013
+ approvalId: current.approvalId,
7014
+ state: current.state,
7015
+ reason: "CHALLENGE_EXPIRED"
7016
+ } });
7017
+ const at = now.toISOString();
7018
+ const binding = current.disclosure;
7019
+ if (current.state === "pending") throw new CommsError("APPROVAL_PENDING", "nothing was disclosed: the standing disclosure approval is pending", { details: {
7020
+ approvalId: current.approvalId,
7021
+ state: current.state
7022
+ } });
7023
+ if (current.state !== "approved") throw new CommsError("APPROVAL_VOID", "nothing was disclosed: the standing disclosure approval was used already", { details: {
7024
+ approvalId: current.approvalId,
7025
+ state: current.state
7026
+ } });
7027
+ if (!sameDisclosureBinding(binding, liveBinding) || current.approvedDigest !== binding.digest) {
7028
+ failure = {
7029
+ code: "APPROVAL_VOID",
7030
+ reason: "the disclosure binding changed after it was approved"
7031
+ };
7032
+ return {
7033
+ ...current,
7034
+ state: "revoked",
7035
+ reason: failure.reason,
7036
+ revokedAt: at,
7037
+ challengeHash: void 0
7038
+ };
7039
+ }
7040
+ return {
7041
+ ...current,
7042
+ state: "used",
7043
+ usedAt: at
7044
+ };
7045
+ });
7046
+ const refused = failure;
7047
+ if (refused !== null) throw refusedAfter(refusalFor(done.record)(refused.code, refused.reason, done.record), done);
7048
+ await this.#markClaimed(done);
7049
+ return done.record;
7050
+ }
6579
7051
  /**
6580
7052
  * A human approved through a confirm channel by typing the issued challenge. The draft must still be exactly what the
6581
7053
  * record was prepared for; otherwise the record is voided, because the human would be approving content the record
@@ -6585,6 +7057,7 @@ var ApprovalStore = class {
6585
7057
  * `createChange`), so a person's typed code means one thing whichever kind of approval it is typed for.
6586
7058
  */
6587
7059
  async approve(approvalId, via, live, answer, kind = "send", _platform = process.platform, options = {}) {
7060
+ if (kind === "disclosure") throw new CommsError("APPROVAL_REQUIRED", "nothing was disclosed: a standing disclosure approval is only accepted on the trusted terminal or app route");
6588
7061
  let failure = null;
6589
7062
  const done = await this.#transition(approvalId, { kind }, "approve", (current, outcome) => {
6590
7063
  if (outcome.error) throw outcome.error;
@@ -7319,6 +7792,7 @@ function fail(reason, attribution = "verified") {
7319
7792
  function checkVerified(record, raw) {
7320
7793
  if (raw.digestVersion !== 2) return fail("digest-version");
7321
7794
  const kind = raw.kind;
7795
+ if (kind === "disclosure") return checkDisclosure(record, raw);
7322
7796
  if (kind !== "send" && kind !== "change" && kind !== "download") return fail("wrong-shape");
7323
7797
  if (!STATES$1.includes(raw.state)) return fail("state-impossible");
7324
7798
  const state = raw.state;
@@ -7342,6 +7816,59 @@ function checkVerified(record, raw) {
7342
7816
  }
7343
7817
  return checkTimestamps(raw, kind, state, times) ?? checkEvidence(record, raw, kind, state);
7344
7818
  }
7819
+ /** Validates the closed disclosure union member without borrowing a sender approval's shape. */
7820
+ function checkDisclosure(record, raw) {
7821
+ if (![
7822
+ "pending",
7823
+ "approved",
7824
+ "used",
7825
+ "expired",
7826
+ "revoked"
7827
+ ].includes(raw.state)) return fail("state-impossible");
7828
+ const disclosure = record.disclosure;
7829
+ if (disclosure === void 0) return fail("wrong-shape");
7830
+ let binding;
7831
+ try {
7832
+ binding = canonicalDisclosureBinding(disclosure);
7833
+ } catch {
7834
+ return fail("wrong-shape");
7835
+ }
7836
+ if (raw.pendingMs !== APPROVAL_LIFETIMES.confirm || raw.approvedMs !== APPROVAL_LIFETIMES.approved) return fail("lifetime-mismatch");
7837
+ const times = {};
7838
+ for (const field of TIMESTAMPS) {
7839
+ const at = timeOf(raw[field]);
7840
+ if (at === "invalid") return fail("timestamp-invalid");
7841
+ if (at !== null) times[field] = at;
7842
+ }
7843
+ const { createdAt, expiresAt, approvedAt, usableUntil, usedAt, revokedAt, expiredAt } = times;
7844
+ if (createdAt === void 0 || expiresAt === void 0) return fail("timestamp-missing");
7845
+ if (expiresAt - createdAt !== APPROVAL_LIFETIMES.confirm) return fail("lifetime-mismatch");
7846
+ const state = raw.state;
7847
+ const approved = approvedAt !== void 0 || usableUntil !== void 0;
7848
+ if (approvedAt === void 0 !== (usableUntil === void 0)) return fail("timestamp-missing");
7849
+ if (approvedAt !== void 0 && usableUntil !== void 0) {
7850
+ if (!(createdAt <= approvedAt && approvedAt < expiresAt)) return fail("timestamp-misordered");
7851
+ if (usableUntil - approvedAt !== APPROVAL_LIFETIMES.approved) return fail("lifetime-mismatch");
7852
+ }
7853
+ if (state === "approved" && !approved) return fail("timestamp-missing");
7854
+ if (state === "used" && usedAt === void 0) return fail("timestamp-missing");
7855
+ if (state !== "used" && usedAt !== void 0) return fail("timestamp-misplaced");
7856
+ if (state === "revoked" && revokedAt === void 0) return fail("timestamp-missing");
7857
+ if (state !== "revoked" && revokedAt !== void 0) return fail("timestamp-misplaced");
7858
+ if (state === "expired" && expiredAt === void 0) return fail("timestamp-missing");
7859
+ if (state !== "expired" && expiredAt !== void 0) return fail("timestamp-misplaced");
7860
+ const deadline = usableUntil ?? expiresAt;
7861
+ if (usedAt !== void 0 && !(approvedAt !== void 0 && approvedAt <= usedAt && usedAt < deadline)) return fail("timestamp-misordered");
7862
+ if (revokedAt !== void 0 && !(createdAt <= revokedAt && revokedAt < deadline)) return fail("timestamp-misordered");
7863
+ if (expiredAt !== void 0 && expiredAt < (approvedAt ?? createdAt)) return fail("timestamp-misordered");
7864
+ if (raw.challengeAttempts !== 0 && raw.challengeAttempts !== 1 && raw.challengeAttempts !== 2 && raw.challengeAttempts !== 3) return fail("evidence-invalid");
7865
+ const via = raw.approvedVia;
7866
+ const digest = raw.approvedDigest;
7867
+ if (!approved) {
7868
+ if (via !== void 0 || digest !== void 0) return fail("evidence-contradictory");
7869
+ } else if (via !== "terminal" && via !== "app" || digest !== binding.digest) return fail("evidence-contradictory");
7870
+ return null;
7871
+ }
7345
7872
  /** The identity the binding covers is also consistent with itself: who it belongs to, and what it is about. */
7346
7873
  function checkIdentity(record, raw, kind) {
7347
7874
  if (!isKnownChannel(raw.channel)) return fail("identity-contradiction");
@@ -7484,7 +8011,8 @@ const STATES = [
7484
8011
  const KINDS$1 = [
7485
8012
  "send",
7486
8013
  "change",
7487
- "download"
8014
+ "download",
8015
+ "disclosure"
7488
8016
  ];
7489
8017
  const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
7490
8018
  const isString = (value) => typeof value === "string";
@@ -7541,6 +8069,59 @@ function commonShape(raw) {
7541
8069
  }
7542
8070
  return null;
7543
8071
  }
8072
+ /** The disclosure union member is deliberately closed: it cannot acquire a send-shaped field by optionality. */
8073
+ function disclosureShape(raw) {
8074
+ for (const field of [
8075
+ "approvalId",
8076
+ "state",
8077
+ "createdAt",
8078
+ "expiresAt",
8079
+ "updatedAt",
8080
+ "bindingDigest"
8081
+ ]) if (!isString(raw[field])) return "wrong-shape";
8082
+ for (const field of [
8083
+ "pendingMs",
8084
+ "approvedMs",
8085
+ "challengeAttempts"
8086
+ ]) if (typeof raw[field] !== "number") return "wrong-shape";
8087
+ for (const field of [
8088
+ "approvedDigest",
8089
+ "approvedVia",
8090
+ "approvedAt",
8091
+ "usableUntil",
8092
+ "challengeHash",
8093
+ "usedAt",
8094
+ "revokedAt",
8095
+ "expiredAt",
8096
+ "reason"
8097
+ ]) if (!optional(raw[field], isString)) return "wrong-shape";
8098
+ const disclosure = raw.disclosure;
8099
+ if (!isObject(disclosure) || !isString(disclosure.digest) || !isString(disclosure.activationIntentId) || !isString(disclosure.activationKind) || !Array.isArray(disclosure.versions) || !disclosure.versions.every((entry) => isObject(entry) && isString(entry.kind) && isString(entry.id) && typeof entry.version === "number")) return "wrong-shape";
8100
+ for (const field of [
8101
+ "channel",
8102
+ "ownerScope",
8103
+ "route",
8104
+ "inboxId",
8105
+ "inboxSub",
8106
+ "draftId",
8107
+ "draftMessageId",
8108
+ "expect",
8109
+ "sendEpoch",
8110
+ "policy",
8111
+ "requiredPolicy",
8112
+ "riskFlags",
8113
+ "approvedBindingDigest",
8114
+ "sendingAt",
8115
+ "sendingHeartbeatAt",
8116
+ "sentAt",
8117
+ "sentMessageId",
8118
+ "failedAt",
8119
+ "change",
8120
+ "download",
8121
+ "contentDigest"
8122
+ ]) if (Object.hasOwn(raw, field)) return "wrong-shape";
8123
+ return null;
8124
+ }
7544
8125
  /** The version-2 fields' types: a number where a number belongs, a string where a string does. */
7545
8126
  function v2Shape(raw) {
7546
8127
  for (const field of [
@@ -7583,6 +8164,24 @@ function decodeStored(fileId, text, config, now) {
7583
8164
  }
7584
8165
  if (!isObject(raw)) return unreadable(fileId, "wrong-shape");
7585
8166
  if (!isString(raw.approvalId)) return unreadable(fileId, "wrong-shape");
8167
+ if (raw.kind === "disclosure") {
8168
+ if (raw.digestVersion === 1) return unreadable(fileId, "wrong-shape");
8169
+ const shape = disclosureShape(raw);
8170
+ if (shape !== null) return unreadable(fileId, shape);
8171
+ const record = raw;
8172
+ const validation = validateV2(record, fileId);
8173
+ if (validation.ok) return {
8174
+ form: "v2",
8175
+ record
8176
+ };
8177
+ return {
8178
+ form: "corrupt",
8179
+ approvalId: fileId,
8180
+ reason: validation.reason,
8181
+ attribution: validation.attribution,
8182
+ safe: validation.attribution === "verified" ? safeFieldsOf(record) : null
8183
+ };
8184
+ }
7586
8185
  const shape = commonShape(raw);
7587
8186
  if (shape !== null) return unreadable(fileId, shape);
7588
8187
  if (raw.digestVersion === 1) {
@@ -7614,6 +8213,7 @@ function decodeStored(fileId, text, config, now) {
7614
8213
  };
7615
8214
  }
7616
8215
  function safeFieldsOf(record) {
8216
+ if (record.kind === "disclosure") return { kind: "disclosure" };
7617
8217
  return {
7618
8218
  kind: record.kind,
7619
8219
  channel: record.channel,
@@ -7641,9 +8241,9 @@ function asLegacy(stored) {
7641
8241
  function ownerOf(stored) {
7642
8242
  if (stored === null) return null;
7643
8243
  switch (stored.form) {
7644
- case "v2": return stored.record.inboxId;
8244
+ case "v2": return stored.record.kind === "disclosure" ? null : stored.record.inboxId;
7645
8245
  case "legacy": return stored.record.inboxId;
7646
- case "corrupt": return stored.safe?.inboxId ?? null;
8246
+ case "corrupt": return stored.safe?.kind === "disclosure" ? null : stored.safe?.inboxId ?? null;
7647
8247
  case "unreadable": return null;
7648
8248
  }
7649
8249
  }
@@ -7665,9 +8265,9 @@ function kindOf$1(stored) {
7665
8265
  function channelOf(stored) {
7666
8266
  if (stored === null) return null;
7667
8267
  switch (stored.form) {
7668
- case "v2": return stored.record.channel;
8268
+ case "v2": return stored.record.kind === "disclosure" ? null : stored.record.channel;
7669
8269
  case "legacy": return stored.view.channel;
7670
- case "corrupt": return stored.safe?.channel ?? null;
8270
+ case "corrupt": return stored.safe?.kind === "disclosure" ? null : stored.safe?.channel ?? null;
7671
8271
  case "unreadable": return null;
7672
8272
  }
7673
8273
  }
@@ -7816,7 +8416,7 @@ function verdictOf(stored, now) {
7816
8416
  const providerId = record.kind === "send" && record.state === "used" && typeof record.sentMessageId === "string" ? record.sentMessageId : "";
7817
8417
  return {
7818
8418
  keep: false,
7819
- owner: record.inboxId,
8419
+ owner: record.kind === "disclosure" ? "" : record.inboxId,
7820
8420
  row: {
7821
8421
  kind: record.kind,
7822
8422
  state: record.state,
@@ -8262,7 +8862,7 @@ function folding(platform) {
8262
8862
  return platform === "win32" ? (text) => text.toLowerCase() : (text) => text;
8263
8863
  }
8264
8864
  /** The package version, read from package.json at build time. */
8265
- const VERSION = "0.15.2";
8865
+ const VERSION = "0.16.0";
8266
8866
  /**
8267
8867
  * Versions as semver writes and orders them — what an update compares, and what the daily update check's reader needs
8268
8868
  * to decide whether the release it last heard of is newer than the one running.
@@ -10534,7 +11134,7 @@ function serverPruneChange(core, env, request, own) {
10534
11134
  };
10535
11135
  const { packageName, version } = ownProduct(request.channel, own) ?? {
10536
11136
  packageName: channelServer(request.channel).packageName,
10537
- version: "0.15.2"
11137
+ version: "0.16.0"
10538
11138
  };
10539
11139
  const product = {
10540
11140
  packageName,
@@ -11188,7 +11788,7 @@ async function askLine(streams, question) {
11188
11788
  }
11189
11789
  channelServer("core").packageName;
11190
11790
  //#endregion
11191
- //#region ../core/dist/tool-arguments-g84XDkWq.mjs
11791
+ //#region ../core/dist/tool-arguments-DA5ZqgoB.mjs
11192
11792
  const APPLIED = Symbol.for("agentcomms.strictToolArguments");
11193
11793
  const TARGET = "draft-2020-12";
11194
11794
  /**
@@ -12076,6 +12676,19 @@ const narrowingSchema = strictObject({
12076
12676
  flag: string().regex(/^--[a-z][a-z-]*$/, "a long flag, `--like-this`"),
12077
12677
  kind: _enum(["pin", "switch"])
12078
12678
  });
12679
+ const eventAccessSchema = discriminatedUnion("kind", [
12680
+ strictObject({
12681
+ kind: literal("oauth-user"),
12682
+ requiredScopes: array(line).min(1)
12683
+ }),
12684
+ strictObject({ kind: literal("resend-full-access") }),
12685
+ strictObject({ kind: literal("local-store") })
12686
+ ]);
12687
+ const eventsSchema = strictObject({
12688
+ types: array(line).min(1),
12689
+ minimumIntervalMs: number().int().positive(),
12690
+ access: eventAccessSchema
12691
+ });
12079
12692
  /**
12080
12693
  * The shapes Gmail and Slack had before there was a manifest, kept because their entries, tools and skills already
12081
12694
  * say them — and nobody else's.
@@ -12158,7 +12771,8 @@ strictObject({
12158
12771
  prefix: string().regex(/^[a-z][a-z0-9]*-$/, "a word and a hyphen: `gmail-`"),
12159
12772
  contract: line
12160
12773
  }).optional(),
12161
- approvalGrouping: _enum(["draft", "draft-revision-digest"]).optional()
12774
+ approvalGrouping: _enum(["draft", "draft-revision-digest"]).optional(),
12775
+ events: eventsSchema.optional()
12162
12776
  }).superRefine((manifest, ctx) => {
12163
12777
  const issue = (path, message) => ctx.addIssue({
12164
12778
  code: "custom",
@@ -13437,6 +14051,25 @@ var ChatListStore = class {
13437
14051
  async of(accountId) {
13438
14052
  return entryOf(await this.#load(), accountId);
13439
14053
  }
14054
+ /**
14055
+ * Reads the live list while holding the same inter-process gate as allow/deny/clear. Event consumers get the
14056
+ * parsed policy and its canonical digest, never a second mutable copy of the list file.
14057
+ */
14058
+ async withCurrent(accountId, work) {
14059
+ return withFileLock(this.#lockPath, async () => {
14060
+ const file = await this.#load();
14061
+ const lists = entryOf(file, accountId);
14062
+ const digest = createHash("sha256").update(canonicalJson$1({
14063
+ allow: [...lists.allow],
14064
+ deny: [...lists.deny]
14065
+ }), "utf8").digest("hex");
14066
+ return work({
14067
+ version: file.version,
14068
+ lists,
14069
+ digest
14070
+ });
14071
+ });
14072
+ }
13440
14073
  /** Changes one account's lists under the file's lock; an account left with neither list has no entry. */
13441
14074
  async update(accountId, change) {
13442
14075
  return withFileLock(this.#lockPath, async () => {
@@ -13838,6 +14471,31 @@ function listKey(jid) {
13838
14471
  const person = /^(\d{7,15})@(?:s\.whatsapp\.net|status)$/.exec(id);
13839
14472
  return person ? `+${person[1]}` : id;
13840
14473
  }
14474
+ /**
14475
+ * The one visibility unit a raw row belongs to. The daemon persists this key, but only this channel defines the
14476
+ * vocabulary: an ordinary chat is its list key; a status post is the pair of its status chat and its author (or no
14477
+ * author), because the lists can hide either one — denying the status feed hides every author in it.
14478
+ */
14479
+ function visibilityUnitKey(chatId, chatKind, senderJid) {
14480
+ if (chatKind !== "status") return listKey(chatId);
14481
+ const author = statusAuthor(chatId, senderJid);
14482
+ return JSON.stringify([
14483
+ STATUS_UNIT,
14484
+ listKey(chatId),
14485
+ author === null ? null : listKey(author)
14486
+ ]);
14487
+ }
14488
+ const STATUS_UNIT = "status-unit";
14489
+ function statusUnit(unitKey) {
14490
+ if (!unitKey.startsWith(`["${STATUS_UNIT}",`)) return void 0;
14491
+ try {
14492
+ const parsed = JSON.parse(unitKey);
14493
+ if (Array.isArray(parsed) && parsed.length === 3 && parsed[0] === STATUS_UNIT && typeof parsed[1] === "string" && (parsed[2] === null || typeof parsed[2] === "string")) return {
14494
+ chat: parsed[1],
14495
+ author: parsed[2]
14496
+ };
14497
+ } catch {}
14498
+ }
13841
14499
  var Visibility = class {
13842
14500
  #allow;
13843
14501
  #deny;
@@ -13861,6 +14519,14 @@ var Visibility = class {
13861
14519
  const author = statusAuthor(chatId, senderJid);
13862
14520
  return author === null ? !this.#hidesAnyone() : this.seesChat(author);
13863
14521
  }
14522
+ /** Whether a durable source unit remains visible under these same lists. */
14523
+ seesUnit(unitKey) {
14524
+ const status = statusUnit(unitKey);
14525
+ if (unitKey.startsWith(`["${STATUS_UNIT}",`) && status === void 0) return false;
14526
+ if (status === void 0) return this.seesChat(unitKey);
14527
+ if (!this.seesChat(status.chat)) return false;
14528
+ return status.author === null ? !this.#hidesAnyone() : this.seesChat(status.author);
14529
+ }
13864
14530
  /** A status post someone else wrote with no author the lists can be checked against: hidden while they hide anyone. */
13865
14531
  unattributed(chatId, chatKind, senderJid, fromMe) {
13866
14532
  return chatKind === "status" && !fromMe && statusAuthor(chatId, senderJid) === null;
@@ -14688,29 +15354,24 @@ function sameLists(a, b) {
14688
15354
  return JSON.stringify([a.allow, a.deny]) === JSON.stringify([b.allow, b.deny]);
14689
15355
  }
14690
15356
  /**
14691
- * Held against a person's commands that land while it runs:
14692
- *
14693
- * - **`remove`** holds the same lock (`WhatsAppContext.syncLock`), so it waits for a running sync and then deletes
14694
- * everything, the new index with it; and a sync that waited behind a remove looks its account up again once it
14695
- * holds the lock, finds it gone, and writes nothing.
14696
- * - **`allow`, `deny`, `clear`**: the lists are read again just before the new index replaces the old. If they
14697
- * changed while it was built, it is built again, from the same copy, with the lists as they are now — so what a
14698
- * deny hides is never written, not merely filtered out when read.
15357
+ * Owns the source-copy lifecycle shared by normal sync and the local event reader. Callers can use values from the
15358
+ * copy only inside `work`; the copy is always closed and removed afterwards.
14699
15359
  */
14700
- async function syncAccount(context, request) {
14701
- const { name, account } = await context.account(request.account);
15360
+ async function withCheckedCopy(context, request, work) {
15361
+ const resolved = request.accountId === void 0 ? await context.account(request.account) : await context.accountById(request.accountId);
15362
+ if (resolved === null) throw new CommsError("NOT_FOUND", "the WhatsApp account was removed before its store was read");
15363
+ const { name, account } = resolved;
14702
15364
  const store = context.storeOf(account);
14703
15365
  const directory = context.accountDir(account);
14704
15366
  return withFileLock(context.syncLock(account), async () => {
14705
- let current = await context.accountById(account.id);
14706
- if (!current) throw removedWhileSyncing(context, name, account);
15367
+ if (!await context.accountById(account.id)) throw removedWhileSyncing(context, name, account);
14707
15368
  await ensurePrivateDir(directory);
14708
15369
  const staleRemoved = await removeStaleSnapshots(directory);
14709
15370
  const snapshot = await snapshotStore(store.path, directory, context.sourceOptions(store.isDefault));
14710
15371
  try {
14711
- const db = await openDatabase(snapshot.database);
15372
+ const database = await openDatabase(snapshot.database);
14712
15373
  try {
14713
- const check = db.prepare("PRAGMA quick_check").get();
15374
+ const check = database.prepare("PRAGMA quick_check").get();
14714
15375
  const verdict = check ? String(Object.values(check)[0]) : "no answer";
14715
15376
  if (verdict !== "ok") throw new CommsError("TRANSIENT", "the copy of WhatsApp’s message store did not pass SQLite’s check", {
14716
15377
  hint: "Nothing was indexed. Try again; if it keeps failing, quit WhatsApp for a moment and sync.",
@@ -14719,38 +15380,18 @@ async function syncAccount(context, request) {
14719
15380
  verdict
14720
15381
  }
14721
15382
  });
14722
- const report = inspectSchema(db);
14723
- for (let round = 1;; round++) {
14724
- const built = current;
14725
- const stats = await rebuildIndex(directory, db, report, {
14726
- indexedAt: context.now().toISOString(),
14727
- copied: snapshot.copied
14728
- }, new Visibility(built.lists), async () => {
14729
- const now = await context.accountById(account.id);
14730
- if (!now) throw removedWhileSyncing(context, built.name, account);
14731
- current = now;
14732
- return sameLists(now.lists, built.lists);
14733
- });
14734
- if (stats) return {
14735
- account: built.name,
14736
- store: {
14737
- path: store.path,
14738
- default: store.isDefault
14739
- },
14740
- ...stats,
14741
- snapshot: {
14742
- attempts: snapshot.attempts,
14743
- deleted: true,
14744
- staleRemoved
14745
- }
14746
- };
14747
- if (round === LIST_ROUNDS) throw new CommsError("TRANSIENT", "the chat lists kept changing while the index was built", {
14748
- hint: "The previous index is kept as it was. Run the sync again.",
14749
- details: { reason: "LISTS_CHANGED" }
14750
- });
14751
- }
15383
+ return await work({
15384
+ accountName: name,
15385
+ account,
15386
+ directory,
15387
+ database,
15388
+ report: inspectSchema(database),
15389
+ snapshot,
15390
+ staleRemoved,
15391
+ store
15392
+ });
14752
15393
  } finally {
14753
- db.close();
15394
+ database.close();
14754
15395
  }
14755
15396
  } finally {
14756
15397
  await snapshot.dispose();
@@ -14760,5 +15401,50 @@ async function syncAccount(context, request) {
14760
15401
  renewMs: 5e3
14761
15402
  });
14762
15403
  }
15404
+ /**
15405
+ * Held against a person's commands that land while it runs:
15406
+ *
15407
+ * - **`remove`** holds the same lock (`WhatsAppContext.syncLock`), so it waits for a running sync and then deletes
15408
+ * everything, the new index with it; and a sync that waited behind a remove looks its account up again once it
15409
+ * holds the lock, finds it gone, and writes nothing.
15410
+ * - **`allow`, `deny`, `clear`**: the lists are read again just before the new index replaces the old. If they
15411
+ * changed while it was built, it is built again, from the same copy, with the lists as they are now — so what a
15412
+ * deny hides is never written, not merely filtered out when read.
15413
+ */
15414
+ async function syncAccount(context, request) {
15415
+ return withCheckedCopy(context, request, async (copy) => {
15416
+ let current = await context.accountById(copy.account.id);
15417
+ if (!current) throw removedWhileSyncing(context, copy.accountName, copy.account);
15418
+ for (let round = 1;; round++) {
15419
+ const built = current;
15420
+ const stats = await rebuildIndex(copy.directory, copy.database, copy.report, {
15421
+ indexedAt: context.now().toISOString(),
15422
+ copied: copy.snapshot.copied
15423
+ }, new Visibility(built.lists), async () => {
15424
+ const now = await context.accountById(copy.account.id);
15425
+ if (!now) throw removedWhileSyncing(context, built.name, copy.account);
15426
+ current = now;
15427
+ return sameLists(now.lists, built.lists);
15428
+ });
15429
+ if (stats) return {
15430
+ account: built.name,
15431
+ store: {
15432
+ path: copy.store.path,
15433
+ default: copy.store.isDefault
15434
+ },
15435
+ ...stats,
15436
+ snapshot: {
15437
+ attempts: copy.snapshot.attempts,
15438
+ deleted: true,
15439
+ staleRemoved: copy.staleRemoved
15440
+ }
15441
+ };
15442
+ if (round === LIST_ROUNDS) throw new CommsError("TRANSIENT", "the chat lists kept changing while the index was built", {
15443
+ hint: "The previous index is kept as it was. Run the sync again.",
15444
+ details: { reason: "LISTS_CHANGED" }
15445
+ });
15446
+ }
15447
+ });
15448
+ }
14763
15449
  //#endregion
14764
- export { paint as $, PATH_OPTIONS as A, colorEnabled as B, checkStorePath as C, CHANNEL_SERVERS as D, strictToolArguments as E, approveAndWaitSentence as F, handoffSentence as G, escapeForDisplay as H, approveChangeAtTerminal as I, isCommsError as J, handoffText as K, canPrompt as L, absoluteSearchPath as M, agentMarker as N, CommsError as O, approvalsOf as P, openCore as Q, childEnvironment as R, storeIdentity as S, expandSource as T, exemptFromUpdateGate as U, commandPathOf as V, gatedChangeAtTerminal as W, nameAvailable as X, lookupName as Y, nameShapeProblem as Z, chatRefOf as _, listChats as a, serverInstallChange as at, requireAccount as b, Presenter as c, truncateDisplay as ct, draftMessage as d, windowsSystemProgram as dt, pathOverridesFromCliOptions as et, Visibility as f, withFileLock as ft, requireSupportedNode as g, VERSION$1 as gt, WhatsAppIndex as h, PACKAGE_NAME as ht, CHAT_KINDS as i, runCommand as it, UNTRUSTED_NOTICE as j, EXIT_CODES as k, innerText as l, updateGateAtTerminal as lt, WhatsAppContext as m, CALLER as mt, whatsappStatus as n, renderPrune as nt, readChat as o, serverPruneChange as ot, listKey as p, writeResult as pt, installExitStatus as q, probeStore as r, resolvePaths as rt, searchMessages as s, toCommsError as st, syncAccount as t, renderInstall as tt, composeDraft as u, updateToolGate as ut, PLATFORM as v, defaultStorePath as w, requireNamedConfig as x, newWhatsAppAccount as y, cliHandoffs as z };
15450
+ export { exemptFromUpdateGate as $, requireAccount as A, PATH_OPTIONS as B, chatKindOf as C, writeResult as Ct, text as D, numeric as E, VERSION$1 as Et, expandSource as F, approveAndWaitSentence as G, absoluteSearchPath as H, strictToolArguments as I, childEnvironment as J, approveChangeAtTerminal as K, CHANNEL_SERVERS as L, storeIdentity as M, checkStorePath as N, PLATFORM as O, defaultStorePath as P, escapeForDisplay as Q, CommsError as R, chatRefOf as S, withFileLock as St, kindOf as T, PACKAGE_NAME as Tt, agentMarker as U, UNTRUSTED_NOTICE as V, approvalsOf as W, colorEnabled as X, cliHandoffs as Y, commandPathOf as Z, WhatsAppContext as _, toCommsError as _t, CHAT_KINDS as a, lookupName as at, requireSupportedNode as b, updateToolGate as bt, searchMessages as c, openCore as ct, composeDraft as d, renderInstall as dt, gatedChangeAtTerminal as et, draftMessage as f, renderPrune as ft, visibilityUnitKey as g, serverPruneChange as gt, statusAuthor as h, serverInstallChange as ht, probeStore as i, isCommsError as it, requireNamedConfig as j, newWhatsAppAccount as k, Presenter as l, paint as lt, listKey as m, runCommand as mt, withCheckedCopy as n, handoffText as nt, listChats as o, nameAvailable as ot, Visibility as p, resolvePaths as pt, canPrompt as q, whatsappStatus as r, installExitStatus as rt, readChat as s, nameShapeProblem as st, syncAccount as t, handoffSentence as tt, innerText as u, pathOverridesFromCliOptions as ut, WhatsAppIndex as v, truncateDisplay as vt, coreDataToIso as w, CALLER as wt, inspectSchema as x, windowsSystemProgram as xt, rebuildIndex as y, updateGateAtTerminal as yt, EXIT_CODES as z };