@atbash/sdk 0.22.0-dev.0 → 0.24.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.22.0-dev.0" : "";
633
+ var SDK_VERSION = true ? "0.24.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)) {
@@ -1389,6 +1439,12 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1389
1439
  const data = parsed;
1390
1440
  const rawScore = data.score;
1391
1441
  const score = typeof rawScore === "number" && Number.isInteger(rawScore) && rawScore >= 1 && rawScore <= 10 ? rawScore : void 0;
1442
+ const statedId = wireText(data.tool_call_id);
1443
+ const statedJudgment = wireText(data.judgment_id);
1444
+ const statedStatus = wireText(data.on_chain_status);
1445
+ const statedJudgeStatus = typeof data.status === "string" ? data.status : "";
1446
+ const boundToCall = (statedId === logResult.toolCallId || statedId === judgmentId) && (!statedJudgment || statedJudgment === judgmentId);
1447
+ const signatureChecked = verifyKeys.length > 0;
1392
1448
  const result = {
1393
1449
  verdict: normalizeVerdict(data.verdict),
1394
1450
  allow: this.executable(data),
@@ -1398,30 +1454,65 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1398
1454
  provider: wireText(data.provider),
1399
1455
  latencyMs: wireNumber(data.latency_ms),
1400
1456
  toolCallId: wireText(data.tool_call_id, logResult.toolCallId),
1457
+ ...wireText(data.judgment_id) ? { judgmentId: wireText(data.judgment_id) } : {},
1401
1458
  onChain: Boolean(data.on_chain),
1459
+ // Only a stated boolean is a statement: `onChain` above reads silence as false.
1460
+ ...typeof data.on_chain === "boolean" ? {
1461
+ record: {
1462
+ onChain: data.on_chain,
1463
+ ...statedStatus ? { status: statedStatus } : {},
1464
+ boundToCall,
1465
+ signatureChecked,
1466
+ ...statedJudgeStatus ? { judgeStatus: statedJudgeStatus } : {},
1467
+ // A judge status marks a reply that carries no verdict: a shadow
1468
+ // ack, or an answer that was only logged.
1469
+ verdictRecorded: data.on_chain && boundToCall && signatureChecked && !statedJudgeStatus
1470
+ }
1471
+ } : {},
1402
1472
  enforced: Boolean(data.enforced),
1403
1473
  enforcementMode: wireText(data.enforcement_mode),
1404
1474
  status: wireText(data.status),
1405
1475
  score,
1406
1476
  refusal: explicitRefusal(data)
1407
1477
  };
1408
- return this.applyJailHold(result, startedAt);
1478
+ if (optIn) {
1479
+ const echo = data.client_enforcement;
1480
+ if (echo !== void 0 && echo !== optInValue || echo === void 0 && this.enforceLocally) {
1481
+ throw new SignatureVerificationError(
1482
+ "the reply does not confirm the enforcement this client asked for"
1483
+ );
1484
+ }
1485
+ if (verifyKeys.length > 0) {
1486
+ if (statedId && statedId !== logResult.toolCallId && statedId !== judgmentId || statedJudgment && statedJudgment !== judgmentId) {
1487
+ throw new SignatureVerificationError(
1488
+ `the signed reply is for call ${statedJudgment || statedId}, not this one`
1489
+ );
1490
+ }
1491
+ if (statedId && echo === optInValue) this.modeTrusted.add(result);
1492
+ }
1493
+ }
1494
+ return this.applyJailHold(result, startedAt, statedStatus);
1409
1495
  }
1410
1496
  /**
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.
1497
+ * Start a local hold on an enforced BLOCK that jails: one already on chain,
1498
+ * or one the judge answered before its chain write landed
1499
+ * (`on_chain_status: "pending"`), which jails once it lands. Refuse any other
1500
+ * result that lands while the agent is held, or whose request was already in
1501
+ * flight when the block arrived: the server judged it on pre-jail state.
1502
+ *
1503
+ * @param onChainStatus - The reply's `on_chain_status`; empty when absent.
1415
1504
  */
1416
- applyJailHold(result, startedAt) {
1505
+ applyJailHold(result, startedAt, onChainStatus = "") {
1417
1506
  const pubkey = this.auth.pubkey;
1418
- if (result.verdict === "BLOCK" && result.enforced && result.onChain) {
1507
+ if (result.verdict === "BLOCK" && result.enforced) {
1419
1508
  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
- );
1509
+ if (result.onChain || onChainStatus === "pending") {
1510
+ holdJail(
1511
+ pubkey,
1512
+ () => this.getAgentPolicy(pubkey).then((p) => p.isJailed),
1513
+ () => this.logger.warn?.("[atbash] local jail hold released: the chain did not confirm the jail in time")
1514
+ );
1515
+ }
1425
1516
  return result;
1426
1517
  }
1427
1518
  return isJailHeld(pubkey) || blockedSince(pubkey, startedAt) ? this.localJailBlock(result.toolCallId) : result;
@@ -1432,7 +1523,7 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1432
1523
  verdict: "BLOCK",
1433
1524
  allow: false,
1434
1525
  actionType: "block",
1435
- reason: "Agent is jailed. All actions are blocked until the organization owner unjails this agent.",
1526
+ reason: `Agent is jailed. All actions are blocked until the organization owner unjails this agent at ${this.endpoint.replace(/\/+$/, "")}/risk-engine/agents.`,
1436
1527
  confidence: 1,
1437
1528
  provider: "policy",
1438
1529
  latencyMs: 0,
@@ -1476,13 +1567,18 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1476
1567
  try {
1477
1568
  this.logger.info?.("[atbash] judge API called", { tool: toolName });
1478
1569
  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
- }),
1570
+ this.judgeTracked(
1571
+ actionText,
1572
+ contextText,
1573
+ {
1574
+ toolName,
1575
+ toolArgsJson: argsJson,
1576
+ orgName: this.orgName,
1577
+ // Passed through unredacted — caller vouches for the contents.
1578
+ resolved: input.resolved
1579
+ },
1580
+ true
1581
+ ),
1486
1582
  this.decisionTimeoutMs
1487
1583
  );
1488
1584
  return this.decide(result);
@@ -1492,7 +1588,7 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1492
1588
  return {
1493
1589
  allow: false,
1494
1590
  verdict: "ERROR",
1495
- reason: `The judge's reply failed its signature check (${errorMessage(err)}), so it was not trusted.`
1591
+ reason: `The judge's reply was not trusted (${errorMessage(err)}).`
1496
1592
  };
1497
1593
  }
1498
1594
  if (err instanceof JudgeRefusalError || err instanceof AtbashAPIError && isRefusalStatus(err.status)) {
@@ -1502,20 +1598,20 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1502
1598
  }
1503
1599
  }
1504
1600
  /**
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.
1601
+ * The verify keys for one call (R4, governor tier-2 review). A per-call key
1602
+ * may only narrow what the client accepts: on a client pinned to judge keys
1603
+ * it must be one of them, never a replacement; on an unpinned client it
1604
+ * adds the check.
1509
1605
  */
1510
- effectiveVerifyKey(perCall) {
1511
- if (perCall === void 0) return this.verifyPubKey;
1606
+ effectiveVerifyKeys(perCall) {
1607
+ if (perCall === void 0) return this.verifyKeys;
1512
1608
  const key = perCall.trim().toLowerCase();
1513
- if (this.verifyPubKey !== void 0 && key !== this.verifyPubKey) {
1609
+ if (this.verifyKeys.length > 0 && !this.verifyKeys.includes(key)) {
1514
1610
  throw new SignatureVerificationError(
1515
1611
  "a per-call verifyPubKey cannot replace the judge key this client is pinned to"
1516
1612
  );
1517
1613
  }
1518
- return key;
1614
+ return [key];
1519
1615
  }
1520
1616
  /**
1521
1617
  * Collapse a judge result into a {@link Decision}. The most restrictive
@@ -1527,7 +1623,12 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1527
1623
  * never overrides a refusal.
1528
1624
  */
1529
1625
  decide(result) {
1530
- const inner = this.decideInner(result);
1626
+ const decided = this.applyMode(result);
1627
+ const inner = {
1628
+ ...decided,
1629
+ ...result.judgmentId ? { judgmentId: result.judgmentId } : {},
1630
+ ...result.record ? { record: result.record } : {}
1631
+ };
1531
1632
  if (!result.enforcementMode) return inner;
1532
1633
  return {
1533
1634
  ...inner,
@@ -1535,6 +1636,50 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1535
1636
  enforcementMode: result.enforcementMode
1536
1637
  };
1537
1638
  }
1639
+ /**
1640
+ * Apply the organisation's enforcement mode to the judge's true verdict.
1641
+ *
1642
+ * Only a mode that arrived signed and bound to this call counts; a missing,
1643
+ * unsigned or unknown mode, or enforceLocally, means enforce. Under
1644
+ * `shadow`, `monitor` or `off` a BLOCK or HOLD runs, reported in
1645
+ * `wouldVerdict`. An ERROR stays an error: it is not a verdict.
1646
+ */
1647
+ applyMode(result) {
1648
+ const mode = result.enforcementMode;
1649
+ const relaxes = this.modeTrusted.has(result) && (mode === "shadow" || mode === "monitor" || mode === "off");
1650
+ if (result.status === "shadow_recorded" && result.verdict === "No verdict") {
1651
+ if (!relaxes || this.enforceLocally) {
1652
+ return {
1653
+ allow: false,
1654
+ verdict: "ERROR",
1655
+ reason: "Atbash recorded this action without judging it (the organisation is not enforcing), and this client requires a verdict.",
1656
+ toolCallId: result.toolCallId
1657
+ };
1658
+ }
1659
+ this.logger.info?.(`[atbash] recorded in ${mode} mode; not judged yet`, {
1660
+ toolCallId: result.toolCallId
1661
+ });
1662
+ return {
1663
+ allow: true,
1664
+ verdict: "ALLOW",
1665
+ reason: `Recorded in ${mode} mode; the verdict appears in the dashboard.`,
1666
+ toolCallId: result.toolCallId
1667
+ };
1668
+ }
1669
+ const inner = this.decideInner(result);
1670
+ if (!relaxes || this.enforceLocally || inner.allow) return inner;
1671
+ if (inner.verdict !== "BLOCK" && inner.verdict !== "HOLD") return inner;
1672
+ this.logger.warn?.(`[atbash] would ${inner.verdict.toLowerCase()} (${mode} mode): ${inner.reason ?? ""}`, {
1673
+ toolCallId: inner.toolCallId
1674
+ });
1675
+ return {
1676
+ allow: true,
1677
+ verdict: "ALLOW",
1678
+ reason: inner.reason,
1679
+ toolCallId: inner.toolCallId,
1680
+ wouldVerdict: inner.verdict
1681
+ };
1682
+ }
1538
1683
  decideInner(result) {
1539
1684
  const toolCallId = result.toolCallId;
1540
1685
  const deny = (verdict, reason) => ({
@@ -1570,7 +1715,9 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1570
1715
  return {
1571
1716
  allow: false,
1572
1717
  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.",
1718
+ // The judge's own reason says why it did not check (the plan, a
1719
+ // deactivated agent); a fixed "audit-tier plan" hid which.
1720
+ 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
1721
  toolCallId
1575
1722
  };
1576
1723
  }
@@ -1660,32 +1807,52 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1660
1807
  const pk = agentPubkey ?? this.auth.pubkey;
1661
1808
  return this.track("getJudgmentStatus", pk, async () => {
1662
1809
  const brid = await this.callBrid(chainOpts);
1810
+ const optIn = this.verifyKeys.length > 0 || this.enforceLocally;
1811
+ const optInValue = this.enforceLocally ? "strict" : "1";
1663
1812
  const resp = await this.http.get(
1664
1813
  "/api/v1/judge",
1665
1814
  { tool_call_id: judgmentId, agent_pubkey: pk, brid },
1666
- this.authHeaders(brid)
1815
+ {
1816
+ ...this.authHeaders(brid),
1817
+ ...optIn ? { "X-Atbash-Client-Enforcement": optInValue } : {}
1818
+ }
1667
1819
  );
1668
1820
  await this.raiseIfError(resp);
1669
1821
  const bytes = Buffer.from(await resp.arrayBuffer());
1670
- if (this.verifyPubKey !== void 0) {
1671
- verifyResponseSignature(bytes, resp, this.verifyPubKey);
1822
+ if (this.verifyKeys.length > 0) {
1823
+ verifyResponseSignature(bytes, resp, this.verifyKeys);
1672
1824
  }
1673
1825
  const parsed = tryParseJson(bytes.toString("utf-8"));
1674
1826
  const data = isRecord2(parsed) ? parsed : {};
1675
- const rawId = data.tool_call_id ?? data.judgmentId;
1827
+ const rawId = data.tool_call_id;
1676
1828
  const statedId = wireText(rawId);
1677
1829
  const sameCall = rawId === void 0 || rawId === null || statedId === judgmentId;
1678
- return {
1830
+ let modeTrusted = false;
1831
+ if (optIn) {
1832
+ const echo = data.client_enforcement;
1833
+ if (echo !== void 0 && echo !== optInValue || echo === void 0 && this.enforceLocally) {
1834
+ throw new SignatureVerificationError(
1835
+ "the status does not confirm the enforcement this client asked for"
1836
+ );
1837
+ }
1838
+ modeTrusted = this.verifyKeys.length > 0 && echo === optInValue && statedId === judgmentId;
1839
+ }
1840
+ const status = {
1679
1841
  status: normalizeStatus(data.status),
1680
1842
  verdict: normalizeVerdict(data.verdict),
1681
1843
  allow: sameCall && this.executable(data),
1682
1844
  reason: wireText(data.reason),
1683
1845
  judgmentId: statedId || judgmentId,
1684
- onChain: optBool(data.onChain),
1685
- cached: optBool(data.cached),
1686
- // The Dashboard sends response_time_ms; responseTimeMs is the spec name.
1687
- responseTimeMs: optNumber(data.responseTimeMs ?? data.response_time_ms)
1846
+ responseTimeMs: optNumber(data.response_time_ms)
1688
1847
  };
1848
+ const mode = wireText(data.enforcement_mode);
1849
+ if (!modeTrusted || this.enforceLocally || !(mode === "shadow" || mode === "monitor" || mode === "off") || status.verdict !== "BLOCK" && status.verdict !== "HOLD") {
1850
+ return status;
1851
+ }
1852
+ this.logger.warn?.(`[atbash] would ${status.verdict.toLowerCase()} (${mode} mode): ${status.reason}`, {
1853
+ toolCallId: judgmentId
1854
+ });
1855
+ return { ...status, verdict: "ALLOW", allow: true, wouldVerdict: status.verdict };
1689
1856
  });
1690
1857
  }
1691
1858
  /* ── risk-engine queries (action-dispatched GET) ───────────────────────── */
@@ -2187,14 +2354,14 @@ async function withDeadline(p, ms) {
2187
2354
  clearTimeout(timer);
2188
2355
  }
2189
2356
  }
2190
- function verifyResponseSignature(bodyBytes, resp, verifyPubKey) {
2357
+ function verifyResponseSignature(bodyBytes, resp, verifyKeys) {
2191
2358
  const sig = resp.headers.get("X-Atbash-Signature");
2192
2359
  if (!sig) {
2193
2360
  throw new SignatureVerificationError("missing X-Atbash-Signature header");
2194
2361
  }
2195
- let ok;
2362
+ let ok = false;
2196
2363
  try {
2197
- ok = native.verifySignature(bodyBytes, sig, verifyPubKey);
2364
+ ok = verifyKeys.some((key) => native.verifySignature(bodyBytes, sig, key));
2198
2365
  } catch (err) {
2199
2366
  throw new SignatureVerificationError(
2200
2367
  `signature verification threw: ${errorMessage(err)}`
@@ -2286,10 +2453,6 @@ function optNumber(v) {
2286
2453
  const n = Number(v);
2287
2454
  return Number.isFinite(n) ? n : void 0;
2288
2455
  }
2289
- function optBool(v) {
2290
- if (v === null || v === void 0) return void 0;
2291
- return Boolean(v);
2292
- }
2293
2456
  function tryParseJson(text) {
2294
2457
  try {
2295
2458
  return JSON.parse(text);
@@ -3968,6 +4131,7 @@ export {
3968
4131
  isEnvelope,
3969
4132
  isJailHeld,
3970
4133
  isValidPrivateKey,
4134
+ judgeVerifyKeys,
3971
4135
  keyFingerprintOf,
3972
4136
  keyPathCandidates,
3973
4137
  loadAgent,
@@ -3994,6 +4158,7 @@ export {
3994
4158
  saveUserConfig,
3995
4159
  scanMemory,
3996
4160
  scanMemoryBatch,
4161
+ setLoopbackJudgePinsForTests,
3997
4162
  setupTelemetry,
3998
4163
  shutdownTelemetry,
3999
4164
  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