@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.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.22.0-dev.0" : "";
758
+ var SDK_VERSION = true ? "0.24.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)) {
@@ -1506,6 +1558,12 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1506
1558
  const data = parsed;
1507
1559
  const rawScore = data.score;
1508
1560
  const score = typeof rawScore === "number" && Number.isInteger(rawScore) && rawScore >= 1 && rawScore <= 10 ? rawScore : void 0;
1561
+ const statedId = wireText(data.tool_call_id);
1562
+ const statedJudgment = wireText(data.judgment_id);
1563
+ const statedStatus = wireText(data.on_chain_status);
1564
+ const statedJudgeStatus = typeof data.status === "string" ? data.status : "";
1565
+ const boundToCall = (statedId === logResult.toolCallId || statedId === judgmentId) && (!statedJudgment || statedJudgment === judgmentId);
1566
+ const signatureChecked = verifyKeys.length > 0;
1509
1567
  const result = {
1510
1568
  verdict: normalizeVerdict(data.verdict),
1511
1569
  allow: this.executable(data),
@@ -1515,30 +1573,65 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1515
1573
  provider: wireText(data.provider),
1516
1574
  latencyMs: wireNumber(data.latency_ms),
1517
1575
  toolCallId: wireText(data.tool_call_id, logResult.toolCallId),
1576
+ ...wireText(data.judgment_id) ? { judgmentId: wireText(data.judgment_id) } : {},
1518
1577
  onChain: Boolean(data.on_chain),
1578
+ // Only a stated boolean is a statement: `onChain` above reads silence as false.
1579
+ ...typeof data.on_chain === "boolean" ? {
1580
+ record: {
1581
+ onChain: data.on_chain,
1582
+ ...statedStatus ? { status: statedStatus } : {},
1583
+ boundToCall,
1584
+ signatureChecked,
1585
+ ...statedJudgeStatus ? { judgeStatus: statedJudgeStatus } : {},
1586
+ // A judge status marks a reply that carries no verdict: a shadow
1587
+ // ack, or an answer that was only logged.
1588
+ verdictRecorded: data.on_chain && boundToCall && signatureChecked && !statedJudgeStatus
1589
+ }
1590
+ } : {},
1519
1591
  enforced: Boolean(data.enforced),
1520
1592
  enforcementMode: wireText(data.enforcement_mode),
1521
1593
  status: wireText(data.status),
1522
1594
  score,
1523
1595
  refusal: explicitRefusal(data)
1524
1596
  };
1525
- return this.applyJailHold(result, startedAt);
1597
+ if (optIn) {
1598
+ const echo = data.client_enforcement;
1599
+ if (echo !== void 0 && echo !== optInValue || echo === void 0 && this.enforceLocally) {
1600
+ throw new SignatureVerificationError(
1601
+ "the reply does not confirm the enforcement this client asked for"
1602
+ );
1603
+ }
1604
+ if (verifyKeys.length > 0) {
1605
+ if (statedId && statedId !== logResult.toolCallId && statedId !== judgmentId || statedJudgment && statedJudgment !== judgmentId) {
1606
+ throw new SignatureVerificationError(
1607
+ `the signed reply is for call ${statedJudgment || statedId}, not this one`
1608
+ );
1609
+ }
1610
+ if (statedId && echo === optInValue) this.modeTrusted.add(result);
1611
+ }
1612
+ }
1613
+ return this.applyJailHold(result, startedAt, statedStatus);
1526
1614
  }
1527
1615
  /**
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.
1616
+ * Start a local hold on an enforced BLOCK that jails: one already on chain,
1617
+ * or one the judge answered before its chain write landed
1618
+ * (`on_chain_status: "pending"`), which jails once it lands. Refuse any other
1619
+ * result that lands while the agent is held, or whose request was already in
1620
+ * flight when the block arrived: the server judged it on pre-jail state.
1621
+ *
1622
+ * @param onChainStatus - The reply's `on_chain_status`; empty when absent.
1532
1623
  */
1533
- applyJailHold(result, startedAt) {
1624
+ applyJailHold(result, startedAt, onChainStatus = "") {
1534
1625
  const pubkey = this.auth.pubkey;
1535
- if (result.verdict === "BLOCK" && result.enforced && result.onChain) {
1626
+ if (result.verdict === "BLOCK" && result.enforced) {
1536
1627
  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
- );
1628
+ if (result.onChain || onChainStatus === "pending") {
1629
+ holdJail(
1630
+ pubkey,
1631
+ () => this.getAgentPolicy(pubkey).then((p) => p.isJailed),
1632
+ () => this.logger.warn?.("[atbash] local jail hold released: the chain did not confirm the jail in time")
1633
+ );
1634
+ }
1542
1635
  return result;
1543
1636
  }
1544
1637
  return isJailHeld(pubkey) || blockedSince(pubkey, startedAt) ? this.localJailBlock(result.toolCallId) : result;
@@ -1549,7 +1642,7 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1549
1642
  verdict: "BLOCK",
1550
1643
  allow: false,
1551
1644
  actionType: "block",
1552
- reason: "Agent is jailed. All actions are blocked until the organization owner unjails this agent.",
1645
+ reason: `Agent is jailed. All actions are blocked until the organization owner unjails this agent at ${this.endpoint.replace(/\/+$/, "")}/risk-engine/agents.`,
1553
1646
  confidence: 1,
1554
1647
  provider: "policy",
1555
1648
  latencyMs: 0,
@@ -1593,13 +1686,18 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1593
1686
  try {
1594
1687
  this.logger.info?.("[atbash] judge API called", { tool: toolName });
1595
1688
  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
- }),
1689
+ this.judgeTracked(
1690
+ actionText,
1691
+ contextText,
1692
+ {
1693
+ toolName,
1694
+ toolArgsJson: argsJson,
1695
+ orgName: this.orgName,
1696
+ // Passed through unredacted — caller vouches for the contents.
1697
+ resolved: input.resolved
1698
+ },
1699
+ true
1700
+ ),
1603
1701
  this.decisionTimeoutMs
1604
1702
  );
1605
1703
  return this.decide(result);
@@ -1609,7 +1707,7 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1609
1707
  return {
1610
1708
  allow: false,
1611
1709
  verdict: "ERROR",
1612
- reason: `The judge's reply failed its signature check (${errorMessage(err)}), so it was not trusted.`
1710
+ reason: `The judge's reply was not trusted (${errorMessage(err)}).`
1613
1711
  };
1614
1712
  }
1615
1713
  if (err instanceof JudgeRefusalError || err instanceof AtbashAPIError && isRefusalStatus(err.status)) {
@@ -1619,20 +1717,20 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1619
1717
  }
1620
1718
  }
1621
1719
  /**
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.
1720
+ * The verify keys for one call (R4, governor tier-2 review). A per-call key
1721
+ * may only narrow what the client accepts: on a client pinned to judge keys
1722
+ * it must be one of them, never a replacement; on an unpinned client it
1723
+ * adds the check.
1626
1724
  */
1627
- effectiveVerifyKey(perCall) {
1628
- if (perCall === void 0) return this.verifyPubKey;
1725
+ effectiveVerifyKeys(perCall) {
1726
+ if (perCall === void 0) return this.verifyKeys;
1629
1727
  const key = perCall.trim().toLowerCase();
1630
- if (this.verifyPubKey !== void 0 && key !== this.verifyPubKey) {
1728
+ if (this.verifyKeys.length > 0 && !this.verifyKeys.includes(key)) {
1631
1729
  throw new SignatureVerificationError(
1632
1730
  "a per-call verifyPubKey cannot replace the judge key this client is pinned to"
1633
1731
  );
1634
1732
  }
1635
- return key;
1733
+ return [key];
1636
1734
  }
1637
1735
  /**
1638
1736
  * Collapse a judge result into a {@link Decision}. The most restrictive
@@ -1644,7 +1742,12 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1644
1742
  * never overrides a refusal.
1645
1743
  */
1646
1744
  decide(result) {
1647
- const inner = this.decideInner(result);
1745
+ const decided = this.applyMode(result);
1746
+ const inner = {
1747
+ ...decided,
1748
+ ...result.judgmentId ? { judgmentId: result.judgmentId } : {},
1749
+ ...result.record ? { record: result.record } : {}
1750
+ };
1648
1751
  if (!result.enforcementMode) return inner;
1649
1752
  return {
1650
1753
  ...inner,
@@ -1652,6 +1755,50 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1652
1755
  enforcementMode: result.enforcementMode
1653
1756
  };
1654
1757
  }
1758
+ /**
1759
+ * Apply the organisation's enforcement mode to the judge's true verdict.
1760
+ *
1761
+ * Only a mode that arrived signed and bound to this call counts; a missing,
1762
+ * unsigned or unknown mode, or enforceLocally, means enforce. Under
1763
+ * `shadow`, `monitor` or `off` a BLOCK or HOLD runs, reported in
1764
+ * `wouldVerdict`. An ERROR stays an error: it is not a verdict.
1765
+ */
1766
+ applyMode(result) {
1767
+ const mode = result.enforcementMode;
1768
+ const relaxes = this.modeTrusted.has(result) && (mode === "shadow" || mode === "monitor" || mode === "off");
1769
+ if (result.status === "shadow_recorded" && result.verdict === "No verdict") {
1770
+ if (!relaxes || this.enforceLocally) {
1771
+ return {
1772
+ allow: false,
1773
+ verdict: "ERROR",
1774
+ reason: "Atbash recorded this action without judging it (the organisation is not enforcing), and this client requires a verdict.",
1775
+ toolCallId: result.toolCallId
1776
+ };
1777
+ }
1778
+ this.logger.info?.(`[atbash] recorded in ${mode} mode; not judged yet`, {
1779
+ toolCallId: result.toolCallId
1780
+ });
1781
+ return {
1782
+ allow: true,
1783
+ verdict: "ALLOW",
1784
+ reason: `Recorded in ${mode} mode; the verdict appears in the dashboard.`,
1785
+ toolCallId: result.toolCallId
1786
+ };
1787
+ }
1788
+ const inner = this.decideInner(result);
1789
+ if (!relaxes || this.enforceLocally || inner.allow) return inner;
1790
+ if (inner.verdict !== "BLOCK" && inner.verdict !== "HOLD") return inner;
1791
+ this.logger.warn?.(`[atbash] would ${inner.verdict.toLowerCase()} (${mode} mode): ${inner.reason ?? ""}`, {
1792
+ toolCallId: inner.toolCallId
1793
+ });
1794
+ return {
1795
+ allow: true,
1796
+ verdict: "ALLOW",
1797
+ reason: inner.reason,
1798
+ toolCallId: inner.toolCallId,
1799
+ wouldVerdict: inner.verdict
1800
+ };
1801
+ }
1655
1802
  decideInner(result) {
1656
1803
  const toolCallId = result.toolCallId;
1657
1804
  const deny = (verdict, reason) => ({
@@ -1687,7 +1834,9 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1687
1834
  return {
1688
1835
  allow: false,
1689
1836
  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.",
1837
+ // The judge's own reason says why it did not check (the plan, a
1838
+ // deactivated agent); a fixed "audit-tier plan" hid which.
1839
+ 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
1840
  toolCallId
1692
1841
  };
1693
1842
  }
@@ -1777,32 +1926,52 @@ config.json still holds an "agentKey" (${identity}), but a settings file no long
1777
1926
  const pk = agentPubkey ?? this.auth.pubkey;
1778
1927
  return this.track("getJudgmentStatus", pk, async () => {
1779
1928
  const brid = await this.callBrid(chainOpts);
1929
+ const optIn = this.verifyKeys.length > 0 || this.enforceLocally;
1930
+ const optInValue = this.enforceLocally ? "strict" : "1";
1780
1931
  const resp = await this.http.get(
1781
1932
  "/api/v1/judge",
1782
1933
  { tool_call_id: judgmentId, agent_pubkey: pk, brid },
1783
- this.authHeaders(brid)
1934
+ {
1935
+ ...this.authHeaders(brid),
1936
+ ...optIn ? { "X-Atbash-Client-Enforcement": optInValue } : {}
1937
+ }
1784
1938
  );
1785
1939
  await this.raiseIfError(resp);
1786
1940
  const bytes = Buffer.from(await resp.arrayBuffer());
1787
- if (this.verifyPubKey !== void 0) {
1788
- verifyResponseSignature(bytes, resp, this.verifyPubKey);
1941
+ if (this.verifyKeys.length > 0) {
1942
+ verifyResponseSignature(bytes, resp, this.verifyKeys);
1789
1943
  }
1790
1944
  const parsed = tryParseJson(bytes.toString("utf-8"));
1791
1945
  const data = isRecord2(parsed) ? parsed : {};
1792
- const rawId = data.tool_call_id ?? data.judgmentId;
1946
+ const rawId = data.tool_call_id;
1793
1947
  const statedId = wireText(rawId);
1794
1948
  const sameCall = rawId === void 0 || rawId === null || statedId === judgmentId;
1795
- return {
1949
+ let modeTrusted = false;
1950
+ if (optIn) {
1951
+ const echo = data.client_enforcement;
1952
+ if (echo !== void 0 && echo !== optInValue || echo === void 0 && this.enforceLocally) {
1953
+ throw new SignatureVerificationError(
1954
+ "the status does not confirm the enforcement this client asked for"
1955
+ );
1956
+ }
1957
+ modeTrusted = this.verifyKeys.length > 0 && echo === optInValue && statedId === judgmentId;
1958
+ }
1959
+ const status = {
1796
1960
  status: normalizeStatus(data.status),
1797
1961
  verdict: normalizeVerdict(data.verdict),
1798
1962
  allow: sameCall && this.executable(data),
1799
1963
  reason: wireText(data.reason),
1800
1964
  judgmentId: statedId || judgmentId,
1801
- onChain: optBool(data.onChain),
1802
- cached: optBool(data.cached),
1803
- // The Dashboard sends response_time_ms; responseTimeMs is the spec name.
1804
- responseTimeMs: optNumber(data.responseTimeMs ?? data.response_time_ms)
1965
+ responseTimeMs: optNumber(data.response_time_ms)
1805
1966
  };
1967
+ const mode = wireText(data.enforcement_mode);
1968
+ if (!modeTrusted || this.enforceLocally || !(mode === "shadow" || mode === "monitor" || mode === "off") || status.verdict !== "BLOCK" && status.verdict !== "HOLD") {
1969
+ return status;
1970
+ }
1971
+ this.logger.warn?.(`[atbash] would ${status.verdict.toLowerCase()} (${mode} mode): ${status.reason}`, {
1972
+ toolCallId: judgmentId
1973
+ });
1974
+ return { ...status, verdict: "ALLOW", allow: true, wouldVerdict: status.verdict };
1806
1975
  });
1807
1976
  }
1808
1977
  /* ── risk-engine queries (action-dispatched GET) ───────────────────────── */
@@ -2304,14 +2473,14 @@ async function withDeadline(p, ms) {
2304
2473
  clearTimeout(timer);
2305
2474
  }
2306
2475
  }
2307
- function verifyResponseSignature(bodyBytes, resp, verifyPubKey) {
2476
+ function verifyResponseSignature(bodyBytes, resp, verifyKeys) {
2308
2477
  const sig = resp.headers.get("X-Atbash-Signature");
2309
2478
  if (!sig) {
2310
2479
  throw new SignatureVerificationError("missing X-Atbash-Signature header");
2311
2480
  }
2312
- let ok;
2481
+ let ok = false;
2313
2482
  try {
2314
- ok = native.verifySignature(bodyBytes, sig, verifyPubKey);
2483
+ ok = verifyKeys.some((key) => native.verifySignature(bodyBytes, sig, key));
2315
2484
  } catch (err) {
2316
2485
  throw new SignatureVerificationError(
2317
2486
  `signature verification threw: ${errorMessage(err)}`
@@ -2403,10 +2572,6 @@ function optNumber(v) {
2403
2572
  const n = Number(v);
2404
2573
  return Number.isFinite(n) ? n : void 0;
2405
2574
  }
2406
- function optBool(v) {
2407
- if (v === null || v === void 0) return void 0;
2408
- return Boolean(v);
2409
- }
2410
2575
  function tryParseJson(text) {
2411
2576
  try {
2412
2577
  return JSON.parse(text);
@@ -4086,6 +4251,7 @@ function diffMemorySnapshots(before, after) {
4086
4251
  isEnvelope,
4087
4252
  isJailHeld,
4088
4253
  isValidPrivateKey,
4254
+ judgeVerifyKeys,
4089
4255
  keyFingerprintOf,
4090
4256
  keyPathCandidates,
4091
4257
  loadAgent,
@@ -4112,6 +4278,7 @@ function diffMemorySnapshots(before, after) {
4112
4278
  saveUserConfig,
4113
4279
  scanMemory,
4114
4280
  scanMemoryBatch,
4281
+ setLoopbackJudgePinsForTests,
4115
4282
  setupTelemetry,
4116
4283
  shutdownTelemetry,
4117
4284
  signEncryptedToolCall,