@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.mjs CHANGED
@@ -104,6 +104,23 @@ function isAllowlistedJudgeHost(endpoint) {
104
104
  return false;
105
105
  }
106
106
  }
107
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
108
+ var loopbackJudgePins = [];
109
+ function setLoopbackJudgePinsForTests(keys) {
110
+ loopbackJudgePins = keys.map((k) => k.trim().toLowerCase());
111
+ }
112
+ function judgeVerifyKeys(endpoint, configuredKey) {
113
+ const host = new URL(endpoint).hostname.toLowerCase();
114
+ const pins = LOOPBACK_HOSTS.has(host) ? loopbackJudgePins : native.pinnedJudgeVerifyKeys(host);
115
+ const key = configuredKey?.trim().toLowerCase();
116
+ if (pins.length === 0) return key ? [key] : [];
117
+ if (key && !pins.includes(key)) {
118
+ throw new Error(
119
+ `[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.`
120
+ );
121
+ }
122
+ return pins;
123
+ }
107
124
  function validateJudgeEndpoint(judge) {
108
125
  const policy = judge?.policy === "self-hosted" ? "self-hosted" : "default";
109
126
  const candidate = judge?.endpoint?.trim() || DEFAULT_ENDPOINT;
@@ -218,7 +235,7 @@ function isJailHeld(pubkey) {
218
235
  return holds.has(pubkey);
219
236
  }
220
237
  function recordBlock(pubkey) {
221
- const now = Date.now();
238
+ const now = performance.now();
222
239
  for (const [key, at] of blockedAt) if (now - at > BLOCK_MEMORY_MS) blockedAt.delete(key);
223
240
  blockedAt.set(pubkey, now);
224
241
  }
@@ -613,7 +630,7 @@ import {
613
630
  import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
614
631
  import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";
615
632
  import { createHash } from "crypto";
616
- var SDK_VERSION = true ? "0.21.0-dev.0" : "";
633
+ var SDK_VERSION = true ? "0.23.0-dev.0" : "";
617
634
  var SDK_LANGUAGE = "node";
618
635
  function sdkResourceAttributes() {
619
636
  const attrs = {
@@ -884,7 +901,10 @@ function forcedChainNetwork(flagValue) {
884
901
 
885
902
  // src-ts/client.ts
886
903
  var AGENT_NOT_REGISTERED = "Agent not registered. Onboard the agent at the dashboard before submitting actions.";
904
+ var TOOL_CALL_IDS_FOR_TESTS = /* @__PURE__ */ Symbol.for("@atbash/sdk.toolCallIdsForTests");
887
905
  function generateToolCallId() {
906
+ const fixed = globalThis[TOOL_CALL_IDS_FOR_TESTS];
907
+ if (fixed && fixed.ids.length > 0) return fixed.ids[fixed.next++ % fixed.ids.length];
888
908
  return `tc-${Date.now()}-${randomHex(4)}`;
889
909
  }
890
910
  function resolveConstructorChain(options) {
@@ -921,12 +941,21 @@ var Atbash = class _Atbash {
921
941
  orgName;
922
942
  /** Default judge response-signing pubkey, if configured (see fromConfig). */
923
943
  verifyPubKey;
944
+ /**
945
+ * The keys every judge reply must be signed by: the host's pinned keys, or
946
+ * the configured `verifyPubKey` on a host with none. Empty means unchecked.
947
+ */
948
+ verifyKeys;
924
949
  /** Default org encryption key — see {@link AtbashOptions.orgEncryptionPubKey}. */
925
950
  orgEncryptionPubKey;
926
951
  /** When true (default), `auditToolCall` denies on any error. */
927
952
  failClosed;
928
953
  /** When true (default), an unenforced (audit-tier) answer is refused. */
929
954
  requireEnforcement;
955
+ /** When true, a signed non-enforcing mode never relaxes a verdict. */
956
+ enforceLocally;
957
+ /** Results whose enforcement mode came signed and bound to their call. */
958
+ modeTrusted = /* @__PURE__ */ new WeakSet();
930
959
  /** Overall deadline for one auditToolCall decision, in ms. */
931
960
  decisionTimeoutMs;
932
961
  /** Org key learned from the last agent-exists check, for this agent only. */
@@ -990,9 +1019,11 @@ var Atbash = class _Atbash {
990
1019
  this._forcedNetwork = forcedChainNetwork();
991
1020
  this.orgName = options.orgName;
992
1021
  this.verifyPubKey = validated.verifyPubKey ?? void 0;
1022
+ this.verifyKeys = judgeVerifyKeys(this.endpoint, this.verifyPubKey);
993
1023
  this.orgEncryptionPubKey = options.orgEncryptionPubKey;
994
1024
  this.failClosed = options.failClosed !== false;
995
1025
  this.requireEnforcement = options.requireEnforcement !== false;
1026
+ this.enforceLocally = options.enforceLocally === true;
996
1027
  this.decisionTimeoutMs = options.decisionTimeoutMs ?? DEFAULT_DECISION_TIMEOUT_MS;
997
1028
  this.debug = options.debug === true;
998
1029
  this.logger = options.logger ?? {};
@@ -1001,7 +1032,7 @@ var Atbash = class _Atbash {
1001
1032
  if (this.endpoint !== DEFAULT_ENDPOINT) {
1002
1033
  this.logger.warn?.("[atbash] running on non-default judge endpoint", {
1003
1034
  endpoint: this.endpoint,
1004
- verifying: this.verifyPubKey ? "with response-signature pubkey configured" : "without signature verification"
1035
+ verifying: this.verifyKeys.length > 0 ? "with response-signature pubkey configured" : "without signature verification"
1005
1036
  });
1006
1037
  }
1007
1038
  if (isAllowlistedJudgeHost(this.endpoint)) {
@@ -1039,12 +1070,12 @@ var Atbash = class _Atbash {
1039
1070
  _Atbash.environmentLogged = true;
1040
1071
  const brief = (rid) => rid ? `${rid.slice(0, 8)}\u2026` : "(unset)";
1041
1072
  this.logger.info?.(
1042
- `[atbash] environment \u2014 judge=${this.endpoint} privateChain=${brief(native.DEFAULT_PRIVATE_BLOCKCHAIN_RID)} activeChain=${brief(this.blockchainRid)} responseSignatureCheck=${this.verifyPubKey ? "on" : "off"}`,
1073
+ `[atbash] environment \u2014 judge=${this.endpoint} privateChain=${brief(native.DEFAULT_PRIVATE_BLOCKCHAIN_RID)} activeChain=${brief(this.blockchainRid)} responseSignatureCheck=${this.verifyKeys.length > 0 ? "on" : "off"}`,
1043
1074
  {
1044
1075
  judgeEndpoint: this.endpoint,
1045
1076
  privateBlockchainRid: native.DEFAULT_PRIVATE_BLOCKCHAIN_RID,
1046
1077
  activeBlockchainRid: this.blockchainRid,
1047
- responseSignatureCheck: Boolean(this.verifyPubKey)
1078
+ responseSignatureCheck: this.verifyKeys.length > 0
1048
1079
  }
1049
1080
  );
1050
1081
  }
@@ -1162,6 +1193,7 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1162
1193
  verifyPubKey: validated.verifyPubKey ?? void 0,
1163
1194
  failClosed: options.failClosed,
1164
1195
  requireEnforcement: options.requireEnforcement,
1196
+ enforceLocally: options.enforceLocally,
1165
1197
  decisionTimeoutMs: options.decisionTimeoutMs,
1166
1198
  // ATBASH_DEBUG lets an operator turn diagnostics on without editing a
1167
1199
  // host's plugin config, which is usually the harder half.
@@ -1296,22 +1328,34 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1296
1328
  * response bytes via the Rust core's `verifySignature`.
1297
1329
  */
1298
1330
  async judgeAction(action, context = "", options = {}) {
1331
+ return this.judgeTracked(action, context, options, false);
1332
+ }
1333
+ /**
1334
+ * judgeAction, optionally asking the judge for the true verdict and the
1335
+ * organisation's enforcement mode so this client applies the mode itself.
1336
+ *
1337
+ * Only auditToolCall opts in: judgeAction's callers keep the server-side
1338
+ * downgrade they gate on today.
1339
+ *
1340
+ * @param clientEnforces - Send `X-Atbash-Client-Enforcement` when the reply can be trusted.
1341
+ */
1342
+ judgeTracked(action, context, options, clientEnforces) {
1299
1343
  return this.track("judgeAction", this.auth.pubkey, async () => {
1300
1344
  try {
1301
- return await this._judgeAction(action, context, options);
1345
+ return await this._judgeAction(action, context, options, clientEnforces);
1302
1346
  } catch (err) {
1303
1347
  if (!isEncryptionStateMismatch(err)) throw err;
1304
1348
  this._orgKeyFromChain = null;
1305
- return await this._judgeAction(action, context, options);
1349
+ return await this._judgeAction(action, context, options, clientEnforces);
1306
1350
  }
1307
1351
  });
1308
1352
  }
1309
- async _judgeAction(action, context, options) {
1353
+ async _judgeAction(action, context, options, clientEnforces = false) {
1310
1354
  if (!action?.trim()) {
1311
1355
  throw new Error("action is required and cannot be empty.");
1312
1356
  }
1313
1357
  if (isJailHeld(this.auth.pubkey)) return this.localJailBlock();
1314
- const startedAt = Date.now();
1358
+ const startedAt = performance.now();
1315
1359
  let chainOpts = options.chainOpts;
1316
1360
  if (options.orgName && this._explicitChain) {
1317
1361
  chainOpts = options.chainOpts ?? {
@@ -1368,17 +1412,23 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1368
1412
  if (options.toolArgsJson) body.tool_args_json = options.toolArgsJson;
1369
1413
  if (options.resolved) body.resolved = options.resolved;
1370
1414
  if (options.mode) body.mode = options.mode;
1415
+ const verifyKeys = this.effectiveVerifyKeys(options.verifyPubKey);
1416
+ const optIn = clientEnforces && (verifyKeys.length > 0 || this.enforceLocally);
1417
+ const optInValue = this.enforceLocally ? "strict" : "1";
1371
1418
  let resp;
1372
1419
  try {
1373
- resp = await this.http.post("/api/v1/judge", body);
1420
+ resp = await this.http.post(
1421
+ "/api/v1/judge",
1422
+ body,
1423
+ optIn ? { "X-Atbash-Client-Enforcement": optInValue } : void 0
1424
+ );
1374
1425
  } catch (err) {
1375
1426
  throw this.transportError(err);
1376
1427
  }
1377
1428
  if (!resp.ok) throw await this.httpError(resp);
1378
1429
  const bodyBytes = Buffer.from(await resp.arrayBuffer());
1379
- const verifyPubKey = this.effectiveVerifyKey(options.verifyPubKey);
1380
- if (verifyPubKey !== void 0) {
1381
- verifyResponseSignature(bodyBytes, resp, verifyPubKey);
1430
+ if (verifyKeys.length > 0) {
1431
+ verifyResponseSignature(bodyBytes, resp, verifyKeys);
1382
1432
  }
1383
1433
  const parsed = tryParseJson(bodyBytes.toString("utf-8"));
1384
1434
  if (!isRecord2(parsed)) {
@@ -1398,6 +1448,7 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1398
1448
  provider: wireText(data.provider),
1399
1449
  latencyMs: wireNumber(data.latency_ms),
1400
1450
  toolCallId: wireText(data.tool_call_id, logResult.toolCallId),
1451
+ ...wireText(data.judgment_id) ? { judgmentId: wireText(data.judgment_id) } : {},
1401
1452
  onChain: Boolean(data.on_chain),
1402
1453
  enforced: Boolean(data.enforced),
1403
1454
  enforcementMode: wireText(data.enforcement_mode),
@@ -1405,23 +1456,46 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1405
1456
  score,
1406
1457
  refusal: explicitRefusal(data)
1407
1458
  };
1408
- return this.applyJailHold(result, startedAt);
1459
+ if (optIn) {
1460
+ const echo = data.client_enforcement;
1461
+ if (echo !== void 0 && echo !== optInValue || echo === void 0 && this.enforceLocally) {
1462
+ throw new SignatureVerificationError(
1463
+ "the reply does not confirm the enforcement this client asked for"
1464
+ );
1465
+ }
1466
+ if (verifyKeys.length > 0) {
1467
+ const statedId = wireText(data.tool_call_id);
1468
+ const statedJudgment = wireText(data.judgment_id);
1469
+ if (statedId && statedId !== logResult.toolCallId && statedId !== judgmentId || statedJudgment && statedJudgment !== judgmentId) {
1470
+ throw new SignatureVerificationError(
1471
+ `the signed reply is for call ${statedJudgment || statedId}, not this one`
1472
+ );
1473
+ }
1474
+ if (statedId && echo === optInValue) this.modeTrusted.add(result);
1475
+ }
1476
+ }
1477
+ return this.applyJailHold(result, startedAt, wireText(data.on_chain_status));
1409
1478
  }
1410
1479
  /**
1411
- * Start a local hold on an enforced, on-chain BLOCK (the verdict that jails).
1412
- * Refuse any other result that lands while the agent is held, or whose
1413
- * request was already in flight when the block arrived: the server judged it
1414
- * on pre-jail state.
1480
+ * Start a local hold on an enforced BLOCK that jails: one already on chain,
1481
+ * or one the judge answered before its chain write landed
1482
+ * (`on_chain_status: "pending"`), which jails once it lands. Refuse any other
1483
+ * result that lands while the agent is held, or whose request was already in
1484
+ * flight when the block arrived: the server judged it on pre-jail state.
1485
+ *
1486
+ * @param onChainStatus - The reply's `on_chain_status`; empty when absent.
1415
1487
  */
1416
- applyJailHold(result, startedAt) {
1488
+ applyJailHold(result, startedAt, onChainStatus = "") {
1417
1489
  const pubkey = this.auth.pubkey;
1418
- if (result.verdict === "BLOCK" && result.enforced && result.onChain) {
1490
+ if (result.verdict === "BLOCK" && result.enforced) {
1419
1491
  recordBlock(pubkey);
1420
- holdJail(
1421
- pubkey,
1422
- () => this.getAgentPolicy(pubkey).then((p) => p.isJailed),
1423
- () => this.logger.warn?.("[atbash] local jail hold released: the chain did not confirm the jail in time")
1424
- );
1492
+ if (result.onChain || onChainStatus === "pending") {
1493
+ holdJail(
1494
+ pubkey,
1495
+ () => this.getAgentPolicy(pubkey).then((p) => p.isJailed),
1496
+ () => this.logger.warn?.("[atbash] local jail hold released: the chain did not confirm the jail in time")
1497
+ );
1498
+ }
1425
1499
  return result;
1426
1500
  }
1427
1501
  return isJailHeld(pubkey) || blockedSince(pubkey, startedAt) ? this.localJailBlock(result.toolCallId) : result;
@@ -1476,13 +1550,18 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1476
1550
  try {
1477
1551
  this.logger.info?.("[atbash] judge API called", { tool: toolName });
1478
1552
  const result = await withDeadline(
1479
- this.judgeAction(actionText, contextText, {
1480
- toolName,
1481
- toolArgsJson: argsJson,
1482
- orgName: this.orgName,
1483
- // Passed through unredacted — caller vouches for the contents.
1484
- resolved: input.resolved
1485
- }),
1553
+ this.judgeTracked(
1554
+ actionText,
1555
+ contextText,
1556
+ {
1557
+ toolName,
1558
+ toolArgsJson: argsJson,
1559
+ orgName: this.orgName,
1560
+ // Passed through unredacted — caller vouches for the contents.
1561
+ resolved: input.resolved
1562
+ },
1563
+ true
1564
+ ),
1486
1565
  this.decisionTimeoutMs
1487
1566
  );
1488
1567
  return this.decide(result);
@@ -1492,7 +1571,7 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1492
1571
  return {
1493
1572
  allow: false,
1494
1573
  verdict: "ERROR",
1495
- reason: `The judge's reply failed its signature check (${errorMessage(err)}), so it was not trusted.`
1574
+ reason: `The judge's reply was not trusted (${errorMessage(err)}).`
1496
1575
  };
1497
1576
  }
1498
1577
  if (err instanceof JudgeRefusalError || err instanceof AtbashAPIError && isRefusalStatus(err.status)) {
@@ -1502,20 +1581,20 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1502
1581
  }
1503
1582
  }
1504
1583
  /**
1505
- * The verify key for one call (R4, governor tier-2 review). A per-call key
1506
- * may only narrow what the client accepts: on a client pinned to a judge
1507
- * key it must be that same key, never a replacement; on an unpinned client
1508
- * it adds the check.
1584
+ * The verify keys for one call (R4, governor tier-2 review). A per-call key
1585
+ * may only narrow what the client accepts: on a client pinned to judge keys
1586
+ * it must be one of them, never a replacement; on an unpinned client it
1587
+ * adds the check.
1509
1588
  */
1510
- effectiveVerifyKey(perCall) {
1511
- if (perCall === void 0) return this.verifyPubKey;
1589
+ effectiveVerifyKeys(perCall) {
1590
+ if (perCall === void 0) return this.verifyKeys;
1512
1591
  const key = perCall.trim().toLowerCase();
1513
- if (this.verifyPubKey !== void 0 && key !== this.verifyPubKey) {
1592
+ if (this.verifyKeys.length > 0 && !this.verifyKeys.includes(key)) {
1514
1593
  throw new SignatureVerificationError(
1515
1594
  "a per-call verifyPubKey cannot replace the judge key this client is pinned to"
1516
1595
  );
1517
1596
  }
1518
- return key;
1597
+ return [key];
1519
1598
  }
1520
1599
  /**
1521
1600
  * Collapse a judge result into a {@link Decision}. The most restrictive
@@ -1527,7 +1606,8 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1527
1606
  * never overrides a refusal.
1528
1607
  */
1529
1608
  decide(result) {
1530
- const inner = this.decideInner(result);
1609
+ const decided = this.applyMode(result);
1610
+ const inner = result.judgmentId ? { ...decided, judgmentId: result.judgmentId } : decided;
1531
1611
  if (!result.enforcementMode) return inner;
1532
1612
  return {
1533
1613
  ...inner,
@@ -1535,6 +1615,50 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1535
1615
  enforcementMode: result.enforcementMode
1536
1616
  };
1537
1617
  }
1618
+ /**
1619
+ * Apply the organisation's enforcement mode to the judge's true verdict.
1620
+ *
1621
+ * Only a mode that arrived signed and bound to this call counts; a missing,
1622
+ * unsigned or unknown mode, or enforceLocally, means enforce. Under
1623
+ * `shadow`, `monitor` or `off` a BLOCK or HOLD runs, reported in
1624
+ * `wouldVerdict`. An ERROR stays an error: it is not a verdict.
1625
+ */
1626
+ applyMode(result) {
1627
+ const mode = result.enforcementMode;
1628
+ const relaxes = this.modeTrusted.has(result) && (mode === "shadow" || mode === "monitor" || mode === "off");
1629
+ if (result.status === "shadow_recorded" && result.verdict === "No verdict") {
1630
+ if (!relaxes || this.enforceLocally) {
1631
+ return {
1632
+ allow: false,
1633
+ verdict: "ERROR",
1634
+ reason: "Atbash recorded this action without judging it (the organisation is not enforcing), and this client requires a verdict.",
1635
+ toolCallId: result.toolCallId
1636
+ };
1637
+ }
1638
+ this.logger.info?.(`[atbash] recorded in ${mode} mode; not judged yet`, {
1639
+ toolCallId: result.toolCallId
1640
+ });
1641
+ return {
1642
+ allow: true,
1643
+ verdict: "ALLOW",
1644
+ reason: `Recorded in ${mode} mode; the verdict appears in the dashboard.`,
1645
+ toolCallId: result.toolCallId
1646
+ };
1647
+ }
1648
+ const inner = this.decideInner(result);
1649
+ if (!relaxes || this.enforceLocally || inner.allow) return inner;
1650
+ if (inner.verdict !== "BLOCK" && inner.verdict !== "HOLD") return inner;
1651
+ this.logger.warn?.(`[atbash] would ${inner.verdict.toLowerCase()} (${mode} mode): ${inner.reason ?? ""}`, {
1652
+ toolCallId: inner.toolCallId
1653
+ });
1654
+ return {
1655
+ allow: true,
1656
+ verdict: "ALLOW",
1657
+ reason: inner.reason,
1658
+ toolCallId: inner.toolCallId,
1659
+ wouldVerdict: inner.verdict
1660
+ };
1661
+ }
1538
1662
  decideInner(result) {
1539
1663
  const toolCallId = result.toolCallId;
1540
1664
  const deny = (verdict, reason) => ({
@@ -1570,7 +1694,9 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1570
1694
  return {
1571
1695
  allow: false,
1572
1696
  verdict: "ERROR",
1573
- 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.",
1697
+ // The judge's own reason says why it did not check (the plan, a
1698
+ // deactivated agent); a fixed "audit-tier plan" hid which.
1699
+ 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.",
1574
1700
  toolCallId
1575
1701
  };
1576
1702
  }
@@ -1660,28 +1786,52 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1660
1786
  const pk = agentPubkey ?? this.auth.pubkey;
1661
1787
  return this.track("getJudgmentStatus", pk, async () => {
1662
1788
  const brid = await this.callBrid(chainOpts);
1789
+ const optIn = this.verifyKeys.length > 0 || this.enforceLocally;
1790
+ const optInValue = this.enforceLocally ? "strict" : "1";
1663
1791
  const resp = await this.http.get(
1664
1792
  "/api/v1/judge",
1665
1793
  { tool_call_id: judgmentId, agent_pubkey: pk, brid },
1666
- this.authHeaders(brid)
1794
+ {
1795
+ ...this.authHeaders(brid),
1796
+ ...optIn ? { "X-Atbash-Client-Enforcement": optInValue } : {}
1797
+ }
1667
1798
  );
1668
1799
  await this.raiseIfError(resp);
1669
1800
  const bytes = Buffer.from(await resp.arrayBuffer());
1670
- if (this.verifyPubKey !== void 0) {
1671
- verifyResponseSignature(bytes, resp, this.verifyPubKey);
1801
+ if (this.verifyKeys.length > 0) {
1802
+ verifyResponseSignature(bytes, resp, this.verifyKeys);
1672
1803
  }
1673
1804
  const parsed = tryParseJson(bytes.toString("utf-8"));
1674
1805
  const data = isRecord2(parsed) ? parsed : {};
1675
- return {
1806
+ const rawId = data.tool_call_id;
1807
+ const statedId = wireText(rawId);
1808
+ const sameCall = rawId === void 0 || rawId === null || statedId === judgmentId;
1809
+ let modeTrusted = false;
1810
+ if (optIn) {
1811
+ const echo = data.client_enforcement;
1812
+ if (echo !== void 0 && echo !== optInValue || echo === void 0 && this.enforceLocally) {
1813
+ throw new SignatureVerificationError(
1814
+ "the status does not confirm the enforcement this client asked for"
1815
+ );
1816
+ }
1817
+ modeTrusted = this.verifyKeys.length > 0 && echo === optInValue && statedId === judgmentId;
1818
+ }
1819
+ const status = {
1676
1820
  status: normalizeStatus(data.status),
1677
1821
  verdict: normalizeVerdict(data.verdict),
1678
- allow: this.executable(data),
1822
+ allow: sameCall && this.executable(data),
1679
1823
  reason: wireText(data.reason),
1680
- judgmentId: wireText(data.judgmentId, judgmentId),
1681
- onChain: optBool(data.onChain),
1682
- cached: optBool(data.cached),
1683
- responseTimeMs: optNumber(data.responseTimeMs)
1824
+ judgmentId: statedId || judgmentId,
1825
+ responseTimeMs: optNumber(data.response_time_ms)
1684
1826
  };
1827
+ const mode = wireText(data.enforcement_mode);
1828
+ if (!modeTrusted || this.enforceLocally || !(mode === "shadow" || mode === "monitor" || mode === "off") || status.verdict !== "BLOCK" && status.verdict !== "HOLD") {
1829
+ return status;
1830
+ }
1831
+ this.logger.warn?.(`[atbash] would ${status.verdict.toLowerCase()} (${mode} mode): ${status.reason}`, {
1832
+ toolCallId: judgmentId
1833
+ });
1834
+ return { ...status, verdict: "ALLOW", allow: true, wouldVerdict: status.verdict };
1685
1835
  });
1686
1836
  }
1687
1837
  /* ── risk-engine queries (action-dispatched GET) ───────────────────────── */
@@ -2183,14 +2333,14 @@ async function withDeadline(p, ms) {
2183
2333
  clearTimeout(timer);
2184
2334
  }
2185
2335
  }
2186
- function verifyResponseSignature(bodyBytes, resp, verifyPubKey) {
2336
+ function verifyResponseSignature(bodyBytes, resp, verifyKeys) {
2187
2337
  const sig = resp.headers.get("X-Atbash-Signature");
2188
2338
  if (!sig) {
2189
2339
  throw new SignatureVerificationError("missing X-Atbash-Signature header");
2190
2340
  }
2191
- let ok;
2341
+ let ok = false;
2192
2342
  try {
2193
- ok = native.verifySignature(bodyBytes, sig, verifyPubKey);
2343
+ ok = verifyKeys.some((key) => native.verifySignature(bodyBytes, sig, key));
2194
2344
  } catch (err) {
2195
2345
  throw new SignatureVerificationError(
2196
2346
  `signature verification threw: ${errorMessage(err)}`
@@ -2282,10 +2432,6 @@ function optNumber(v) {
2282
2432
  const n = Number(v);
2283
2433
  return Number.isFinite(n) ? n : void 0;
2284
2434
  }
2285
- function optBool(v) {
2286
- if (v === null || v === void 0) return void 0;
2287
- return Boolean(v);
2288
- }
2289
2435
  function tryParseJson(text) {
2290
2436
  try {
2291
2437
  return JSON.parse(text);
@@ -2616,14 +2762,15 @@ async function decryptRow(row, key) {
2616
2762
  updatedAt: parsed.updated_at
2617
2763
  };
2618
2764
  }
2619
- async function readRows(action, auth, chainOpts, filePath) {
2765
+ async function readRows(action, auth, chainOpts, filePath, extra = {}) {
2620
2766
  const key = await deriveMemoryKey(auth.privkey);
2621
2767
  const rows = await memoryQuery(
2622
2768
  action,
2623
2769
  auth,
2624
2770
  resolveBlockchainRid(chainOpts),
2625
2771
  chainOpts,
2626
- filePath
2772
+ filePath,
2773
+ extra
2627
2774
  );
2628
2775
  return Promise.all((rows ?? []).map((row) => decryptRow(row, key)));
2629
2776
  }
@@ -2643,8 +2790,29 @@ function getRecentAgentMemory(auth, chainOpts, filePath) {
2643
2790
  function getActiveAgentMemory(auth, chainOpts, filePath) {
2644
2791
  return readRows("memory-active", auth, chainOpts, filePath ?? null);
2645
2792
  }
2646
- function getMemoryHistory(auth, chainOpts, filePath) {
2647
- return readRows("memory-history", auth, chainOpts, filePath ?? null);
2793
+ var MEMORY_HISTORY_MAX_COUNT = 200;
2794
+ function memoryHistoryPageArgs(page) {
2795
+ const extra = {};
2796
+ for (const [name, value] of [
2797
+ ["max_count", page?.maxCount],
2798
+ ["before_id", page?.beforeId]
2799
+ ]) {
2800
+ if (value === void 0) continue;
2801
+ if (!Number.isSafeInteger(value) || value < 1) {
2802
+ throw new Error(`getMemoryHistory: ${name} must be a positive integer`);
2803
+ }
2804
+ if (name === "max_count" && value > MEMORY_HISTORY_MAX_COUNT) {
2805
+ throw new Error(`getMemoryHistory: max_count must be at most ${MEMORY_HISTORY_MAX_COUNT}`);
2806
+ }
2807
+ extra[name] = value;
2808
+ }
2809
+ return extra;
2810
+ }
2811
+ async function getMemoryHistory(auth, chainOpts, filePath, page) {
2812
+ const rows = await readRows("memory-history", auth, chainOpts, filePath ?? null, memoryHistoryPageArgs(page));
2813
+ const beforeId = page?.beforeId;
2814
+ const older = beforeId === void 0 ? rows : rows.filter((row) => row.id < beforeId);
2815
+ return page?.maxCount === void 0 ? older : older.slice(0, page.maxCount);
2648
2816
  }
2649
2817
  async function getMemoryById(id, auth, chainOpts) {
2650
2818
  native.validateMemoryId(id);
@@ -3942,6 +4110,7 @@ export {
3942
4110
  isEnvelope,
3943
4111
  isJailHeld,
3944
4112
  isValidPrivateKey,
4113
+ judgeVerifyKeys,
3945
4114
  keyFingerprintOf,
3946
4115
  keyPathCandidates,
3947
4116
  loadAgent,
@@ -3968,6 +4137,7 @@ export {
3968
4137
  saveUserConfig,
3969
4138
  scanMemory,
3970
4139
  scanMemoryBatch,
4140
+ setLoopbackJudgePinsForTests,
3971
4141
  setupTelemetry,
3972
4142
  shutdownTelemetry,
3973
4143
  signEncryptedToolCall,
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Helpers for the SDK's own tests, published as `@atbash/sdk/testing` and kept
3
+ * out of the main entry: fixing the call ids in production would let an old
4
+ * signed reply pass as the answer to a new call.
5
+ */
6
+ /**
7
+ * Fix the ids the client generates, handed out in turn, so a test can serve a
8
+ * judge reply signed for them. A judge call takes two: its call id, then its
9
+ * judgment id.
10
+ *
11
+ * @param ids - One id or several, used in turn; `undefined` restores random ids.
12
+ */
13
+ declare function setToolCallIdForTests(ids: string | readonly string[] | undefined): void;
14
+
15
+ export { setToolCallIdForTests };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Helpers for the SDK's own tests, published as `@atbash/sdk/testing` and kept
3
+ * out of the main entry: fixing the call ids in production would let an old
4
+ * signed reply pass as the answer to a new call.
5
+ */
6
+ /**
7
+ * Fix the ids the client generates, handed out in turn, so a test can serve a
8
+ * judge reply signed for them. A judge call takes two: its call id, then its
9
+ * judgment id.
10
+ *
11
+ * @param ids - One id or several, used in turn; `undefined` restores random ids.
12
+ */
13
+ declare function setToolCallIdForTests(ids: string | readonly string[] | undefined): void;
14
+
15
+ export { setToolCallIdForTests };
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src-ts/testing.ts
21
+ var testing_exports = {};
22
+ __export(testing_exports, {
23
+ setToolCallIdForTests: () => setToolCallIdForTests
24
+ });
25
+ module.exports = __toCommonJS(testing_exports);
26
+ var TOOL_CALL_IDS_FOR_TESTS = /* @__PURE__ */ Symbol.for("@atbash/sdk.toolCallIdsForTests");
27
+ function setToolCallIdForTests(ids) {
28
+ globalThis[TOOL_CALL_IDS_FOR_TESTS] = ids === void 0 ? void 0 : { ids: typeof ids === "string" ? [ids] : [...ids], next: 0 };
29
+ }
30
+ // Annotate the CommonJS export names for ESM import in node:
31
+ 0 && (module.exports = {
32
+ setToolCallIdForTests
33
+ });
@@ -0,0 +1,8 @@
1
+ // src-ts/testing.ts
2
+ var TOOL_CALL_IDS_FOR_TESTS = /* @__PURE__ */ Symbol.for("@atbash/sdk.toolCallIdsForTests");
3
+ function setToolCallIdForTests(ids) {
4
+ globalThis[TOOL_CALL_IDS_FOR_TESTS] = ids === void 0 ? void 0 : { ids: typeof ids === "string" ? [ids] : [...ids], next: 0 };
5
+ }
6
+ export {
7
+ setToolCallIdForTests
8
+ };
package/index.d.ts CHANGED
@@ -236,6 +236,9 @@ export declare function parseRollbackRows(value: any): Array<RawRollbackRowJs>
236
236
 
237
237
  export declare function parseScoreFromReason(reason: string): ParsedScoreJs
238
238
 
239
+ /** The response-signing pubkeys pinned for a judge host; empty when it has none. */
240
+ export declare function pinnedJudgeVerifyKeys(host: string): Array<string>
241
+
239
242
  export declare const QUERY_GET_ACTIVE_AGENT_MEMORY: string
240
243
 
241
244
  export declare const QUERY_GET_ACTIVE_MEMORY_ID: string