@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/README.md +6 -1
- package/dist/browser.d.mts +147 -10
- package/dist/browser.mjs +356 -59
- package/dist/index.d.mts +147 -10
- package/dist/index.d.ts +147 -10
- package/dist/index.js +227 -60
- package/dist/index.mjs +225 -60
- package/dist/testing.d.mts +15 -0
- package/dist/testing.d.ts +15 -0
- package/dist/testing.js +33 -0
- package/dist/testing.mjs +8 -0
- package/index.d.ts +3 -0
- package/index.js +55 -54
- package/package.json +15 -7
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 =
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
1497
|
-
|
|
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
|
-
|
|
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
|
|
1529
|
-
*
|
|
1530
|
-
*
|
|
1531
|
-
*
|
|
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
|
|
1626
|
+
if (result.verdict === "BLOCK" && result.enforced) {
|
|
1536
1627
|
recordBlock(pubkey);
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
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:
|
|
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.
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
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
|
|
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
|
|
1623
|
-
* may only narrow what the client accepts: on a client pinned to
|
|
1624
|
-
*
|
|
1625
|
-
*
|
|
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
|
-
|
|
1628
|
-
if (perCall === void 0) return this.
|
|
1725
|
+
effectiveVerifyKeys(perCall) {
|
|
1726
|
+
if (perCall === void 0) return this.verifyKeys;
|
|
1629
1727
|
const key = perCall.trim().toLowerCase();
|
|
1630
|
-
if (this.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1788
|
-
verifyResponseSignature(bytes, resp, this.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|