@atbash/sdk 0.21.0-dev.0 → 0.23.0-dev.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/dist/index.js CHANGED
@@ -89,6 +89,7 @@ __export(src_ts_exports, {
89
89
  isEnvelope: () => isEnvelope,
90
90
  isJailHeld: () => isJailHeld,
91
91
  isValidPrivateKey: () => isValidPrivateKey,
92
+ judgeVerifyKeys: () => judgeVerifyKeys,
92
93
  keyFingerprintOf: () => keyFingerprintOf,
93
94
  keyPathCandidates: () => keyPathCandidates,
94
95
  loadAgent: () => loadAgent,
@@ -115,6 +116,7 @@ __export(src_ts_exports, {
115
116
  saveUserConfig: () => saveUserConfig,
116
117
  scanMemory: () => scanMemory,
117
118
  scanMemoryBatch: () => scanMemoryBatch,
119
+ setLoopbackJudgePinsForTests: () => setLoopbackJudgePinsForTests,
118
120
  setupTelemetry: () => setupTelemetry,
119
121
  shutdownTelemetry: () => shutdownTelemetry,
120
122
  signEncryptedToolCall: () => signEncryptedToolCall,
@@ -233,6 +235,23 @@ function isAllowlistedJudgeHost(endpoint) {
233
235
  return false;
234
236
  }
235
237
  }
238
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
239
+ var loopbackJudgePins = [];
240
+ function setLoopbackJudgePinsForTests(keys) {
241
+ loopbackJudgePins = keys.map((k) => k.trim().toLowerCase());
242
+ }
243
+ function judgeVerifyKeys(endpoint, configuredKey) {
244
+ const host = new URL(endpoint).hostname.toLowerCase();
245
+ const pins = LOOPBACK_HOSTS.has(host) ? loopbackJudgePins : native.pinnedJudgeVerifyKeys(host);
246
+ const key = configuredKey?.trim().toLowerCase();
247
+ if (pins.length === 0) return key ? [key] : [];
248
+ if (key && !pins.includes(key)) {
249
+ throw new Error(
250
+ `[atbash] verifyPubKey ${key} is not the response-signing key pinned for ${host}. Refusing to load \u2014 an Atbash host's replies are checked against the key built into the SDK; remove verifyPubKey, or set it to the pinned key.`
251
+ );
252
+ }
253
+ return pins;
254
+ }
236
255
  function validateJudgeEndpoint(judge) {
237
256
  const policy = judge?.policy === "self-hosted" ? "self-hosted" : "default";
238
257
  const candidate = judge?.endpoint?.trim() || DEFAULT_ENDPOINT;
@@ -347,7 +366,7 @@ function isJailHeld(pubkey) {
347
366
  return holds.has(pubkey);
348
367
  }
349
368
  function recordBlock(pubkey) {
350
- const now = Date.now();
369
+ const now = performance.now();
351
370
  for (const [key, at] of blockedAt) if (now - at > BLOCK_MEMORY_MS) blockedAt.delete(key);
352
371
  blockedAt.set(pubkey, now);
353
372
  }
@@ -736,7 +755,7 @@ var import_sdk_metrics = require("@opentelemetry/sdk-metrics");
736
755
  var import_exporter_logs_otlp_http = require("@opentelemetry/exporter-logs-otlp-http");
737
756
  var import_sdk_logs = require("@opentelemetry/sdk-logs");
738
757
  var import_node_crypto = require("crypto");
739
- var SDK_VERSION = true ? "0.21.0-dev.0" : "";
758
+ var SDK_VERSION = true ? "0.23.0-dev.0" : "";
740
759
  var SDK_LANGUAGE = "node";
741
760
  function sdkResourceAttributes() {
742
761
  const attrs = {
@@ -1001,7 +1020,10 @@ function forcedChainNetwork(flagValue) {
1001
1020
 
1002
1021
  // src-ts/client.ts
1003
1022
  var AGENT_NOT_REGISTERED = "Agent not registered. Onboard the agent at the dashboard before submitting actions.";
1023
+ var TOOL_CALL_IDS_FOR_TESTS = /* @__PURE__ */ Symbol.for("@atbash/sdk.toolCallIdsForTests");
1004
1024
  function generateToolCallId() {
1025
+ const fixed = globalThis[TOOL_CALL_IDS_FOR_TESTS];
1026
+ if (fixed && fixed.ids.length > 0) return fixed.ids[fixed.next++ % fixed.ids.length];
1005
1027
  return `tc-${Date.now()}-${randomHex(4)}`;
1006
1028
  }
1007
1029
  function resolveConstructorChain(options) {
@@ -1038,12 +1060,21 @@ var Atbash = class _Atbash {
1038
1060
  orgName;
1039
1061
  /** Default judge response-signing pubkey, if configured (see fromConfig). */
1040
1062
  verifyPubKey;
1063
+ /**
1064
+ * The keys every judge reply must be signed by: the host's pinned keys, or
1065
+ * the configured `verifyPubKey` on a host with none. Empty means unchecked.
1066
+ */
1067
+ verifyKeys;
1041
1068
  /** Default org encryption key — see {@link AtbashOptions.orgEncryptionPubKey}. */
1042
1069
  orgEncryptionPubKey;
1043
1070
  /** When true (default), `auditToolCall` denies on any error. */
1044
1071
  failClosed;
1045
1072
  /** When true (default), an unenforced (audit-tier) answer is refused. */
1046
1073
  requireEnforcement;
1074
+ /** When true, a signed non-enforcing mode never relaxes a verdict. */
1075
+ enforceLocally;
1076
+ /** Results whose enforcement mode came signed and bound to their call. */
1077
+ modeTrusted = /* @__PURE__ */ new WeakSet();
1047
1078
  /** Overall deadline for one auditToolCall decision, in ms. */
1048
1079
  decisionTimeoutMs;
1049
1080
  /** Org key learned from the last agent-exists check, for this agent only. */
@@ -1107,9 +1138,11 @@ var Atbash = class _Atbash {
1107
1138
  this._forcedNetwork = forcedChainNetwork();
1108
1139
  this.orgName = options.orgName;
1109
1140
  this.verifyPubKey = validated.verifyPubKey ?? void 0;
1141
+ this.verifyKeys = judgeVerifyKeys(this.endpoint, this.verifyPubKey);
1110
1142
  this.orgEncryptionPubKey = options.orgEncryptionPubKey;
1111
1143
  this.failClosed = options.failClosed !== false;
1112
1144
  this.requireEnforcement = options.requireEnforcement !== false;
1145
+ this.enforceLocally = options.enforceLocally === true;
1113
1146
  this.decisionTimeoutMs = options.decisionTimeoutMs ?? DEFAULT_DECISION_TIMEOUT_MS;
1114
1147
  this.debug = options.debug === true;
1115
1148
  this.logger = options.logger ?? {};
@@ -1118,7 +1151,7 @@ var Atbash = class _Atbash {
1118
1151
  if (this.endpoint !== DEFAULT_ENDPOINT) {
1119
1152
  this.logger.warn?.("[atbash] running on non-default judge endpoint", {
1120
1153
  endpoint: this.endpoint,
1121
- verifying: this.verifyPubKey ? "with response-signature pubkey configured" : "without signature verification"
1154
+ verifying: this.verifyKeys.length > 0 ? "with response-signature pubkey configured" : "without signature verification"
1122
1155
  });
1123
1156
  }
1124
1157
  if (isAllowlistedJudgeHost(this.endpoint)) {
@@ -1156,12 +1189,12 @@ var Atbash = class _Atbash {
1156
1189
  _Atbash.environmentLogged = true;
1157
1190
  const brief = (rid) => rid ? `${rid.slice(0, 8)}\u2026` : "(unset)";
1158
1191
  this.logger.info?.(
1159
- `[atbash] environment \u2014 judge=${this.endpoint} privateChain=${brief(native.DEFAULT_PRIVATE_BLOCKCHAIN_RID)} activeChain=${brief(this.blockchainRid)} responseSignatureCheck=${this.verifyPubKey ? "on" : "off"}`,
1192
+ `[atbash] environment \u2014 judge=${this.endpoint} privateChain=${brief(native.DEFAULT_PRIVATE_BLOCKCHAIN_RID)} activeChain=${brief(this.blockchainRid)} responseSignatureCheck=${this.verifyKeys.length > 0 ? "on" : "off"}`,
1160
1193
  {
1161
1194
  judgeEndpoint: this.endpoint,
1162
1195
  privateBlockchainRid: native.DEFAULT_PRIVATE_BLOCKCHAIN_RID,
1163
1196
  activeBlockchainRid: this.blockchainRid,
1164
- responseSignatureCheck: Boolean(this.verifyPubKey)
1197
+ responseSignatureCheck: this.verifyKeys.length > 0
1165
1198
  }
1166
1199
  );
1167
1200
  }
@@ -1279,6 +1312,7 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1279
1312
  verifyPubKey: validated.verifyPubKey ?? void 0,
1280
1313
  failClosed: options.failClosed,
1281
1314
  requireEnforcement: options.requireEnforcement,
1315
+ enforceLocally: options.enforceLocally,
1282
1316
  decisionTimeoutMs: options.decisionTimeoutMs,
1283
1317
  // ATBASH_DEBUG lets an operator turn diagnostics on without editing a
1284
1318
  // host's plugin config, which is usually the harder half.
@@ -1413,22 +1447,34 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1413
1447
  * response bytes via the Rust core's `verifySignature`.
1414
1448
  */
1415
1449
  async judgeAction(action, context = "", options = {}) {
1450
+ return this.judgeTracked(action, context, options, false);
1451
+ }
1452
+ /**
1453
+ * judgeAction, optionally asking the judge for the true verdict and the
1454
+ * organisation's enforcement mode so this client applies the mode itself.
1455
+ *
1456
+ * Only auditToolCall opts in: judgeAction's callers keep the server-side
1457
+ * downgrade they gate on today.
1458
+ *
1459
+ * @param clientEnforces - Send `X-Atbash-Client-Enforcement` when the reply can be trusted.
1460
+ */
1461
+ judgeTracked(action, context, options, clientEnforces) {
1416
1462
  return this.track("judgeAction", this.auth.pubkey, async () => {
1417
1463
  try {
1418
- return await this._judgeAction(action, context, options);
1464
+ return await this._judgeAction(action, context, options, clientEnforces);
1419
1465
  } catch (err) {
1420
1466
  if (!isEncryptionStateMismatch(err)) throw err;
1421
1467
  this._orgKeyFromChain = null;
1422
- return await this._judgeAction(action, context, options);
1468
+ return await this._judgeAction(action, context, options, clientEnforces);
1423
1469
  }
1424
1470
  });
1425
1471
  }
1426
- async _judgeAction(action, context, options) {
1472
+ async _judgeAction(action, context, options, clientEnforces = false) {
1427
1473
  if (!action?.trim()) {
1428
1474
  throw new Error("action is required and cannot be empty.");
1429
1475
  }
1430
1476
  if (isJailHeld(this.auth.pubkey)) return this.localJailBlock();
1431
- const startedAt = Date.now();
1477
+ const startedAt = performance.now();
1432
1478
  let chainOpts = options.chainOpts;
1433
1479
  if (options.orgName && this._explicitChain) {
1434
1480
  chainOpts = options.chainOpts ?? {
@@ -1485,17 +1531,23 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1485
1531
  if (options.toolArgsJson) body.tool_args_json = options.toolArgsJson;
1486
1532
  if (options.resolved) body.resolved = options.resolved;
1487
1533
  if (options.mode) body.mode = options.mode;
1534
+ const verifyKeys = this.effectiveVerifyKeys(options.verifyPubKey);
1535
+ const optIn = clientEnforces && (verifyKeys.length > 0 || this.enforceLocally);
1536
+ const optInValue = this.enforceLocally ? "strict" : "1";
1488
1537
  let resp;
1489
1538
  try {
1490
- resp = await this.http.post("/api/v1/judge", body);
1539
+ resp = await this.http.post(
1540
+ "/api/v1/judge",
1541
+ body,
1542
+ optIn ? { "X-Atbash-Client-Enforcement": optInValue } : void 0
1543
+ );
1491
1544
  } catch (err) {
1492
1545
  throw this.transportError(err);
1493
1546
  }
1494
1547
  if (!resp.ok) throw await this.httpError(resp);
1495
1548
  const bodyBytes = Buffer.from(await resp.arrayBuffer());
1496
- const verifyPubKey = this.effectiveVerifyKey(options.verifyPubKey);
1497
- if (verifyPubKey !== void 0) {
1498
- verifyResponseSignature(bodyBytes, resp, verifyPubKey);
1549
+ if (verifyKeys.length > 0) {
1550
+ verifyResponseSignature(bodyBytes, resp, verifyKeys);
1499
1551
  }
1500
1552
  const parsed = tryParseJson(bodyBytes.toString("utf-8"));
1501
1553
  if (!isRecord2(parsed)) {
@@ -1515,6 +1567,7 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1515
1567
  provider: wireText(data.provider),
1516
1568
  latencyMs: wireNumber(data.latency_ms),
1517
1569
  toolCallId: wireText(data.tool_call_id, logResult.toolCallId),
1570
+ ...wireText(data.judgment_id) ? { judgmentId: wireText(data.judgment_id) } : {},
1518
1571
  onChain: Boolean(data.on_chain),
1519
1572
  enforced: Boolean(data.enforced),
1520
1573
  enforcementMode: wireText(data.enforcement_mode),
@@ -1522,23 +1575,46 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1522
1575
  score,
1523
1576
  refusal: explicitRefusal(data)
1524
1577
  };
1525
- return this.applyJailHold(result, startedAt);
1578
+ if (optIn) {
1579
+ const echo = data.client_enforcement;
1580
+ if (echo !== void 0 && echo !== optInValue || echo === void 0 && this.enforceLocally) {
1581
+ throw new SignatureVerificationError(
1582
+ "the reply does not confirm the enforcement this client asked for"
1583
+ );
1584
+ }
1585
+ if (verifyKeys.length > 0) {
1586
+ const statedId = wireText(data.tool_call_id);
1587
+ const statedJudgment = wireText(data.judgment_id);
1588
+ if (statedId && statedId !== logResult.toolCallId && statedId !== judgmentId || statedJudgment && statedJudgment !== judgmentId) {
1589
+ throw new SignatureVerificationError(
1590
+ `the signed reply is for call ${statedJudgment || statedId}, not this one`
1591
+ );
1592
+ }
1593
+ if (statedId && echo === optInValue) this.modeTrusted.add(result);
1594
+ }
1595
+ }
1596
+ return this.applyJailHold(result, startedAt, wireText(data.on_chain_status));
1526
1597
  }
1527
1598
  /**
1528
- * Start a local hold on an enforced, on-chain BLOCK (the verdict that jails).
1529
- * Refuse any other result that lands while the agent is held, or whose
1530
- * request was already in flight when the block arrived: the server judged it
1531
- * on pre-jail state.
1599
+ * Start a local hold on an enforced BLOCK that jails: one already on chain,
1600
+ * or one the judge answered before its chain write landed
1601
+ * (`on_chain_status: "pending"`), which jails once it lands. Refuse any other
1602
+ * result that lands while the agent is held, or whose request was already in
1603
+ * flight when the block arrived: the server judged it on pre-jail state.
1604
+ *
1605
+ * @param onChainStatus - The reply's `on_chain_status`; empty when absent.
1532
1606
  */
1533
- applyJailHold(result, startedAt) {
1607
+ applyJailHold(result, startedAt, onChainStatus = "") {
1534
1608
  const pubkey = this.auth.pubkey;
1535
- if (result.verdict === "BLOCK" && result.enforced && result.onChain) {
1609
+ if (result.verdict === "BLOCK" && result.enforced) {
1536
1610
  recordBlock(pubkey);
1537
- holdJail(
1538
- pubkey,
1539
- () => this.getAgentPolicy(pubkey).then((p) => p.isJailed),
1540
- () => this.logger.warn?.("[atbash] local jail hold released: the chain did not confirm the jail in time")
1541
- );
1611
+ if (result.onChain || onChainStatus === "pending") {
1612
+ holdJail(
1613
+ pubkey,
1614
+ () => this.getAgentPolicy(pubkey).then((p) => p.isJailed),
1615
+ () => this.logger.warn?.("[atbash] local jail hold released: the chain did not confirm the jail in time")
1616
+ );
1617
+ }
1542
1618
  return result;
1543
1619
  }
1544
1620
  return isJailHeld(pubkey) || blockedSince(pubkey, startedAt) ? this.localJailBlock(result.toolCallId) : result;
@@ -1593,13 +1669,18 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1593
1669
  try {
1594
1670
  this.logger.info?.("[atbash] judge API called", { tool: toolName });
1595
1671
  const result = await withDeadline(
1596
- this.judgeAction(actionText, contextText, {
1597
- toolName,
1598
- toolArgsJson: argsJson,
1599
- orgName: this.orgName,
1600
- // Passed through unredacted — caller vouches for the contents.
1601
- resolved: input.resolved
1602
- }),
1672
+ this.judgeTracked(
1673
+ actionText,
1674
+ contextText,
1675
+ {
1676
+ toolName,
1677
+ toolArgsJson: argsJson,
1678
+ orgName: this.orgName,
1679
+ // Passed through unredacted — caller vouches for the contents.
1680
+ resolved: input.resolved
1681
+ },
1682
+ true
1683
+ ),
1603
1684
  this.decisionTimeoutMs
1604
1685
  );
1605
1686
  return this.decide(result);
@@ -1609,7 +1690,7 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1609
1690
  return {
1610
1691
  allow: false,
1611
1692
  verdict: "ERROR",
1612
- reason: `The judge's reply failed its signature check (${errorMessage(err)}), so it was not trusted.`
1693
+ reason: `The judge's reply was not trusted (${errorMessage(err)}).`
1613
1694
  };
1614
1695
  }
1615
1696
  if (err instanceof JudgeRefusalError || err instanceof AtbashAPIError && isRefusalStatus(err.status)) {
@@ -1619,20 +1700,20 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1619
1700
  }
1620
1701
  }
1621
1702
  /**
1622
- * The verify key for one call (R4, governor tier-2 review). A per-call key
1623
- * may only narrow what the client accepts: on a client pinned to a judge
1624
- * key it must be that same key, never a replacement; on an unpinned client
1625
- * it adds the check.
1703
+ * The verify keys for one call (R4, governor tier-2 review). A per-call key
1704
+ * may only narrow what the client accepts: on a client pinned to judge keys
1705
+ * it must be one of them, never a replacement; on an unpinned client it
1706
+ * adds the check.
1626
1707
  */
1627
- effectiveVerifyKey(perCall) {
1628
- if (perCall === void 0) return this.verifyPubKey;
1708
+ effectiveVerifyKeys(perCall) {
1709
+ if (perCall === void 0) return this.verifyKeys;
1629
1710
  const key = perCall.trim().toLowerCase();
1630
- if (this.verifyPubKey !== void 0 && key !== this.verifyPubKey) {
1711
+ if (this.verifyKeys.length > 0 && !this.verifyKeys.includes(key)) {
1631
1712
  throw new SignatureVerificationError(
1632
1713
  "a per-call verifyPubKey cannot replace the judge key this client is pinned to"
1633
1714
  );
1634
1715
  }
1635
- return key;
1716
+ return [key];
1636
1717
  }
1637
1718
  /**
1638
1719
  * Collapse a judge result into a {@link Decision}. The most restrictive
@@ -1644,7 +1725,8 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1644
1725
  * never overrides a refusal.
1645
1726
  */
1646
1727
  decide(result) {
1647
- const inner = this.decideInner(result);
1728
+ const decided = this.applyMode(result);
1729
+ const inner = result.judgmentId ? { ...decided, judgmentId: result.judgmentId } : decided;
1648
1730
  if (!result.enforcementMode) return inner;
1649
1731
  return {
1650
1732
  ...inner,
@@ -1652,6 +1734,50 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1652
1734
  enforcementMode: result.enforcementMode
1653
1735
  };
1654
1736
  }
1737
+ /**
1738
+ * Apply the organisation's enforcement mode to the judge's true verdict.
1739
+ *
1740
+ * Only a mode that arrived signed and bound to this call counts; a missing,
1741
+ * unsigned or unknown mode, or enforceLocally, means enforce. Under
1742
+ * `shadow`, `monitor` or `off` a BLOCK or HOLD runs, reported in
1743
+ * `wouldVerdict`. An ERROR stays an error: it is not a verdict.
1744
+ */
1745
+ applyMode(result) {
1746
+ const mode = result.enforcementMode;
1747
+ const relaxes = this.modeTrusted.has(result) && (mode === "shadow" || mode === "monitor" || mode === "off");
1748
+ if (result.status === "shadow_recorded" && result.verdict === "No verdict") {
1749
+ if (!relaxes || this.enforceLocally) {
1750
+ return {
1751
+ allow: false,
1752
+ verdict: "ERROR",
1753
+ reason: "Atbash recorded this action without judging it (the organisation is not enforcing), and this client requires a verdict.",
1754
+ toolCallId: result.toolCallId
1755
+ };
1756
+ }
1757
+ this.logger.info?.(`[atbash] recorded in ${mode} mode; not judged yet`, {
1758
+ toolCallId: result.toolCallId
1759
+ });
1760
+ return {
1761
+ allow: true,
1762
+ verdict: "ALLOW",
1763
+ reason: `Recorded in ${mode} mode; the verdict appears in the dashboard.`,
1764
+ toolCallId: result.toolCallId
1765
+ };
1766
+ }
1767
+ const inner = this.decideInner(result);
1768
+ if (!relaxes || this.enforceLocally || inner.allow) return inner;
1769
+ if (inner.verdict !== "BLOCK" && inner.verdict !== "HOLD") return inner;
1770
+ this.logger.warn?.(`[atbash] would ${inner.verdict.toLowerCase()} (${mode} mode): ${inner.reason ?? ""}`, {
1771
+ toolCallId: inner.toolCallId
1772
+ });
1773
+ return {
1774
+ allow: true,
1775
+ verdict: "ALLOW",
1776
+ reason: inner.reason,
1777
+ toolCallId: inner.toolCallId,
1778
+ wouldVerdict: inner.verdict
1779
+ };
1780
+ }
1655
1781
  decideInner(result) {
1656
1782
  const toolCallId = result.toolCallId;
1657
1783
  const deny = (verdict, reason) => ({
@@ -1687,7 +1813,9 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1687
1813
  return {
1688
1814
  allow: false,
1689
1815
  verdict: "ERROR",
1690
- reason: "Atbash logged this action but did not check it (audit-tier plan), and this client requires a checked decision. Upgrade the plan, or set requireEnforcement: false for log-only mode.",
1816
+ // The judge's own reason says why it did not check (the plan, a
1817
+ // deactivated agent); a fixed "audit-tier plan" hid which.
1818
+ reason: result.reason ? `Atbash did not check this action, and this client requires a checked decision. The judge said: ${result.reason}` : "Atbash logged this action but did not check it (audit-tier plan), and this client requires a checked decision. Upgrade the plan, or set requireEnforcement: false for log-only mode.",
1691
1819
  toolCallId
1692
1820
  };
1693
1821
  }
@@ -1777,28 +1905,52 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1777
1905
  const pk = agentPubkey ?? this.auth.pubkey;
1778
1906
  return this.track("getJudgmentStatus", pk, async () => {
1779
1907
  const brid = await this.callBrid(chainOpts);
1908
+ const optIn = this.verifyKeys.length > 0 || this.enforceLocally;
1909
+ const optInValue = this.enforceLocally ? "strict" : "1";
1780
1910
  const resp = await this.http.get(
1781
1911
  "/api/v1/judge",
1782
1912
  { tool_call_id: judgmentId, agent_pubkey: pk, brid },
1783
- this.authHeaders(brid)
1913
+ {
1914
+ ...this.authHeaders(brid),
1915
+ ...optIn ? { "X-Atbash-Client-Enforcement": optInValue } : {}
1916
+ }
1784
1917
  );
1785
1918
  await this.raiseIfError(resp);
1786
1919
  const bytes = Buffer.from(await resp.arrayBuffer());
1787
- if (this.verifyPubKey !== void 0) {
1788
- verifyResponseSignature(bytes, resp, this.verifyPubKey);
1920
+ if (this.verifyKeys.length > 0) {
1921
+ verifyResponseSignature(bytes, resp, this.verifyKeys);
1789
1922
  }
1790
1923
  const parsed = tryParseJson(bytes.toString("utf-8"));
1791
1924
  const data = isRecord2(parsed) ? parsed : {};
1792
- return {
1925
+ const rawId = data.tool_call_id;
1926
+ const statedId = wireText(rawId);
1927
+ const sameCall = rawId === void 0 || rawId === null || statedId === judgmentId;
1928
+ let modeTrusted = false;
1929
+ if (optIn) {
1930
+ const echo = data.client_enforcement;
1931
+ if (echo !== void 0 && echo !== optInValue || echo === void 0 && this.enforceLocally) {
1932
+ throw new SignatureVerificationError(
1933
+ "the status does not confirm the enforcement this client asked for"
1934
+ );
1935
+ }
1936
+ modeTrusted = this.verifyKeys.length > 0 && echo === optInValue && statedId === judgmentId;
1937
+ }
1938
+ const status = {
1793
1939
  status: normalizeStatus(data.status),
1794
1940
  verdict: normalizeVerdict(data.verdict),
1795
- allow: this.executable(data),
1941
+ allow: sameCall && this.executable(data),
1796
1942
  reason: wireText(data.reason),
1797
- judgmentId: wireText(data.judgmentId, judgmentId),
1798
- onChain: optBool(data.onChain),
1799
- cached: optBool(data.cached),
1800
- responseTimeMs: optNumber(data.responseTimeMs)
1943
+ judgmentId: statedId || judgmentId,
1944
+ responseTimeMs: optNumber(data.response_time_ms)
1801
1945
  };
1946
+ const mode = wireText(data.enforcement_mode);
1947
+ if (!modeTrusted || this.enforceLocally || !(mode === "shadow" || mode === "monitor" || mode === "off") || status.verdict !== "BLOCK" && status.verdict !== "HOLD") {
1948
+ return status;
1949
+ }
1950
+ this.logger.warn?.(`[atbash] would ${status.verdict.toLowerCase()} (${mode} mode): ${status.reason}`, {
1951
+ toolCallId: judgmentId
1952
+ });
1953
+ return { ...status, verdict: "ALLOW", allow: true, wouldVerdict: status.verdict };
1802
1954
  });
1803
1955
  }
1804
1956
  /* ── risk-engine queries (action-dispatched GET) ───────────────────────── */
@@ -2300,14 +2452,14 @@ async function withDeadline(p, ms) {
2300
2452
  clearTimeout(timer);
2301
2453
  }
2302
2454
  }
2303
- function verifyResponseSignature(bodyBytes, resp, verifyPubKey) {
2455
+ function verifyResponseSignature(bodyBytes, resp, verifyKeys) {
2304
2456
  const sig = resp.headers.get("X-Atbash-Signature");
2305
2457
  if (!sig) {
2306
2458
  throw new SignatureVerificationError("missing X-Atbash-Signature header");
2307
2459
  }
2308
- let ok;
2460
+ let ok = false;
2309
2461
  try {
2310
- ok = native.verifySignature(bodyBytes, sig, verifyPubKey);
2462
+ ok = verifyKeys.some((key) => native.verifySignature(bodyBytes, sig, key));
2311
2463
  } catch (err) {
2312
2464
  throw new SignatureVerificationError(
2313
2465
  `signature verification threw: ${errorMessage(err)}`
@@ -2399,10 +2551,6 @@ function optNumber(v) {
2399
2551
  const n = Number(v);
2400
2552
  return Number.isFinite(n) ? n : void 0;
2401
2553
  }
2402
- function optBool(v) {
2403
- if (v === null || v === void 0) return void 0;
2404
- return Boolean(v);
2405
- }
2406
2554
  function tryParseJson(text) {
2407
2555
  try {
2408
2556
  return JSON.parse(text);
@@ -2733,14 +2881,15 @@ async function decryptRow(row, key) {
2733
2881
  updatedAt: parsed.updated_at
2734
2882
  };
2735
2883
  }
2736
- async function readRows(action, auth, chainOpts, filePath) {
2884
+ async function readRows(action, auth, chainOpts, filePath, extra = {}) {
2737
2885
  const key = await deriveMemoryKey(auth.privkey);
2738
2886
  const rows = await memoryQuery(
2739
2887
  action,
2740
2888
  auth,
2741
2889
  resolveBlockchainRid(chainOpts),
2742
2890
  chainOpts,
2743
- filePath
2891
+ filePath,
2892
+ extra
2744
2893
  );
2745
2894
  return Promise.all((rows ?? []).map((row) => decryptRow(row, key)));
2746
2895
  }
@@ -2760,8 +2909,29 @@ function getRecentAgentMemory(auth, chainOpts, filePath) {
2760
2909
  function getActiveAgentMemory(auth, chainOpts, filePath) {
2761
2910
  return readRows("memory-active", auth, chainOpts, filePath ?? null);
2762
2911
  }
2763
- function getMemoryHistory(auth, chainOpts, filePath) {
2764
- return readRows("memory-history", auth, chainOpts, filePath ?? null);
2912
+ var MEMORY_HISTORY_MAX_COUNT = 200;
2913
+ function memoryHistoryPageArgs(page) {
2914
+ const extra = {};
2915
+ for (const [name, value] of [
2916
+ ["max_count", page?.maxCount],
2917
+ ["before_id", page?.beforeId]
2918
+ ]) {
2919
+ if (value === void 0) continue;
2920
+ if (!Number.isSafeInteger(value) || value < 1) {
2921
+ throw new Error(`getMemoryHistory: ${name} must be a positive integer`);
2922
+ }
2923
+ if (name === "max_count" && value > MEMORY_HISTORY_MAX_COUNT) {
2924
+ throw new Error(`getMemoryHistory: max_count must be at most ${MEMORY_HISTORY_MAX_COUNT}`);
2925
+ }
2926
+ extra[name] = value;
2927
+ }
2928
+ return extra;
2929
+ }
2930
+ async function getMemoryHistory(auth, chainOpts, filePath, page) {
2931
+ const rows = await readRows("memory-history", auth, chainOpts, filePath ?? null, memoryHistoryPageArgs(page));
2932
+ const beforeId = page?.beforeId;
2933
+ const older = beforeId === void 0 ? rows : rows.filter((row) => row.id < beforeId);
2934
+ return page?.maxCount === void 0 ? older : older.slice(0, page.maxCount);
2765
2935
  }
2766
2936
  async function getMemoryById(id, auth, chainOpts) {
2767
2937
  native.validateMemoryId(id);
@@ -4060,6 +4230,7 @@ function diffMemorySnapshots(before, after) {
4060
4230
  isEnvelope,
4061
4231
  isJailHeld,
4062
4232
  isValidPrivateKey,
4233
+ judgeVerifyKeys,
4063
4234
  keyFingerprintOf,
4064
4235
  keyPathCandidates,
4065
4236
  loadAgent,
@@ -4086,6 +4257,7 @@ function diffMemorySnapshots(before, after) {
4086
4257
  saveUserConfig,
4087
4258
  scanMemory,
4088
4259
  scanMemoryBatch,
4260
+ setLoopbackJudgePinsForTests,
4089
4261
  setupTelemetry,
4090
4262
  shutdownTelemetry,
4091
4263
  signEncryptedToolCall,