@atbash/atbash-mcp 0.0.12-dev.0 → 0.0.12-dev.2
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 +11 -497
- package/dist/no-execution-gate.test.js +103 -0
- package/dist/prompts/safety.js +29 -0
- package/dist/resources/policy.js +6 -0
- package/dist/startup.js +124 -0
- package/dist/tools/judge.js +137 -0
- package/dist/tools/judge.test.js +104 -0
- package/dist/tools/queries.js +150 -0
- package/dist/tools/status.js +22 -0
- package/dist/utils.js +17 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,512 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
// src/index.ts
|
|
4
2
|
import { Atbash, setupTelemetry, shutdownTelemetry } from "@atbash/sdk";
|
|
5
3
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
15
|
-
promptServer.registerPrompt(
|
|
16
|
-
"atbash_safety_check",
|
|
17
|
-
{
|
|
18
|
-
description: "Prepare a consistent pre-execution Atbash safety review request.",
|
|
19
|
-
argsSchema: {
|
|
20
|
-
action: z.string().describe("Action to evaluate"),
|
|
21
|
-
context: z.string().describe("Why the action is being taken")
|
|
22
|
-
}
|
|
23
|
-
},
|
|
24
|
-
async ({ action, context }) => ({
|
|
25
|
-
messages: [
|
|
26
|
-
{
|
|
27
|
-
role: "user",
|
|
28
|
-
content: {
|
|
29
|
-
type: "text",
|
|
30
|
-
text: [
|
|
31
|
-
"Use the Atbash tools to evaluate the following action before execution.",
|
|
32
|
-
`Action: ${action}`,
|
|
33
|
-
`Context: ${context}`,
|
|
34
|
-
"If the result is HOLD, explain that operator review is required before proceeding."
|
|
35
|
-
].join("\n")
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
]
|
|
39
|
-
})
|
|
40
|
-
);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// src/resources/policy.ts
|
|
44
|
-
function registerPolicyResource(server2, client2) {
|
|
45
|
-
void server2;
|
|
46
|
-
void client2;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// src/startup.ts
|
|
50
|
-
function assertMcpSdkCapabilities(sdk) {
|
|
51
|
-
const missing = [
|
|
52
|
-
typeof sdk.fromConfig === "function" ? null : "fromConfig",
|
|
53
|
-
typeof sdk.prototype?.auditToolCall === "function" ? null : "auditToolCall"
|
|
54
|
-
].filter((name) => name !== null);
|
|
55
|
-
if (missing.length > 0) {
|
|
56
|
-
throw new Error(
|
|
57
|
-
`Incompatible @atbash/sdk: missing required MCP capabilities: ${missing.join(", ")}. Refusing to start without an active safety gate.`
|
|
58
|
-
);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
var DECISION_VERDICTS = [
|
|
62
|
-
"ALLOW",
|
|
63
|
-
"HOLD",
|
|
64
|
-
"BLOCK",
|
|
65
|
-
"ERROR"
|
|
66
|
-
];
|
|
67
|
-
function isDecisionShaped(value) {
|
|
68
|
-
const decision = value;
|
|
69
|
-
return typeof decision === "object" && decision !== null && typeof decision.allow === "boolean" && DECISION_VERDICTS.includes(decision.verdict);
|
|
70
|
-
}
|
|
71
|
-
function hardenJudgeClient(client2) {
|
|
72
|
-
const sdkAudit = client2.auditToolCall;
|
|
73
|
-
const sdkJudge = client2.judgeAction;
|
|
74
|
-
async function guardedAuditToolCall(input) {
|
|
75
|
-
let wire;
|
|
76
|
-
const shadow = Object.create(client2);
|
|
77
|
-
Object.defineProperty(shadow, "judgeAction", {
|
|
78
|
-
value: async (...args) => {
|
|
79
|
-
const result = await sdkJudge.apply(client2, args);
|
|
80
|
-
wire = result;
|
|
81
|
-
return result;
|
|
82
|
-
}
|
|
83
|
-
});
|
|
84
|
-
const decision = await sdkAudit.call(shadow, input);
|
|
85
|
-
if (!isDecisionShaped(decision)) {
|
|
86
|
-
throw new Error(
|
|
87
|
-
"judge decision is not verdict-shaped and cannot be trusted"
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
if (decision.verdict !== "ALLOW" || decision.allow !== true) {
|
|
91
|
-
return { ...decision, allow: false };
|
|
92
|
-
}
|
|
93
|
-
if (wire === void 0) {
|
|
94
|
-
throw new Error(
|
|
95
|
-
"judge returned an allow without a judge response to back it"
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
if (wire.verdict !== "ALLOW" || wire.actionType !== "allow") {
|
|
99
|
-
throw new Error(
|
|
100
|
-
`judge response body does not carry an allow (verdict ${JSON.stringify(wire.verdict)}, action_type ${JSON.stringify(wire.actionType)})`
|
|
101
|
-
);
|
|
102
|
-
}
|
|
103
|
-
return decision;
|
|
104
|
-
}
|
|
105
|
-
Object.defineProperty(client2, "auditToolCall", {
|
|
106
|
-
value: guardedAuditToolCall,
|
|
107
|
-
writable: true,
|
|
108
|
-
configurable: true
|
|
109
|
-
});
|
|
110
|
-
return client2;
|
|
111
|
-
}
|
|
112
|
-
function createAtbashClient(env, sdk) {
|
|
113
|
-
assertMcpSdkCapabilities(sdk);
|
|
114
|
-
const agentKey = env.ATBASH_AGENT_PRIVKEY;
|
|
115
|
-
if (!agentKey) {
|
|
116
|
-
throw new Error("ATBASH_AGENT_PRIVKEY env var is required");
|
|
117
|
-
}
|
|
118
|
-
const endpoint = env.ATBASH_ENDPOINT?.trim();
|
|
119
|
-
const orgName = env.ATBASH_ORG_NAME?.trim();
|
|
120
|
-
return hardenJudgeClient(
|
|
121
|
-
sdk.fromConfig({
|
|
122
|
-
agentKey,
|
|
123
|
-
...endpoint ? { judge: { endpoint } } : {},
|
|
124
|
-
...orgName ? { orgName } : {}
|
|
125
|
-
})
|
|
126
|
-
);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// src/tools/judge.ts
|
|
130
|
-
import { z as z2 } from "zod";
|
|
131
|
-
|
|
132
|
-
// src/utils.ts
|
|
133
|
-
function safeErrorMessage(error) {
|
|
134
|
-
return error instanceof Error ? error.message : String(error);
|
|
135
|
-
}
|
|
136
|
-
function toJsonContent(value) {
|
|
137
|
-
return {
|
|
138
|
-
content: [{ type: "text", text: JSON.stringify(value, null, 2) }]
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
function toErrorContent(error) {
|
|
142
|
-
return {
|
|
143
|
-
content: [{ type: "text", text: `Error: ${safeErrorMessage(error)}` }],
|
|
144
|
-
isError: true
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// src/tools/judge.ts
|
|
149
|
-
function toJudgeFailureContent(error, toolName = "atbash_judge") {
|
|
150
|
-
return {
|
|
151
|
-
content: [
|
|
152
|
-
{
|
|
153
|
-
type: "text",
|
|
154
|
-
text: JSON.stringify(
|
|
155
|
-
{
|
|
156
|
-
verdict: "ERROR",
|
|
157
|
-
allow: false,
|
|
158
|
-
reason: `Atbash judge unavailable \u2014 treat this as BLOCK and do NOT proceed with the action (fail-closed). Retry ${toolName} once the service is reachable. Cause: ${safeErrorMessage(error)}`
|
|
159
|
-
},
|
|
160
|
-
null,
|
|
161
|
-
2
|
|
162
|
-
)
|
|
163
|
-
}
|
|
164
|
-
],
|
|
165
|
-
isError: true
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
function denyUnlessExplicitlyAllowed(decision) {
|
|
169
|
-
return decision.verdict === "ALLOW" && decision.allow === true ? decision : { ...decision, allow: false };
|
|
170
|
-
}
|
|
171
|
-
function registerJudgeTools(server2, client2) {
|
|
172
|
-
server2.registerTool(
|
|
173
|
-
"atbash_judge",
|
|
174
|
-
{
|
|
175
|
-
description: "Submit an action for safety judgment before executing it. Returns ALLOW, HOLD, BLOCK, or ERROR.",
|
|
176
|
-
inputSchema: z2.object({
|
|
177
|
-
action: z2.string().describe("Plain text description of the action to judge"),
|
|
178
|
-
context: z2.string().describe("Why this action is being taken"),
|
|
179
|
-
tool_name: z2.string().optional().describe("Name of the tool being called"),
|
|
180
|
-
tool_args_json: z2.string().optional().describe("JSON string of tool arguments")
|
|
181
|
-
})
|
|
182
|
-
},
|
|
183
|
-
async ({ action, context, tool_name, tool_args_json }) => {
|
|
184
|
-
try {
|
|
185
|
-
let parsedArgs = void 0;
|
|
186
|
-
if (tool_args_json) {
|
|
187
|
-
try {
|
|
188
|
-
parsedArgs = JSON.parse(tool_args_json);
|
|
189
|
-
} catch {
|
|
190
|
-
parsedArgs = tool_args_json;
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
const rawDecision = await client2.auditToolCall({
|
|
194
|
-
toolName: tool_name ?? "mcp_judge",
|
|
195
|
-
args: parsedArgs ?? { action },
|
|
196
|
-
context: `${action} \u2014 ${context}`
|
|
197
|
-
});
|
|
198
|
-
const decision = denyUnlessExplicitlyAllowed(rawDecision);
|
|
199
|
-
if (decision.verdict === "ERROR") {
|
|
200
|
-
return toJudgeFailureContent(
|
|
201
|
-
new Error(decision.reason ?? "judge returned an error verdict")
|
|
202
|
-
);
|
|
203
|
-
}
|
|
204
|
-
if (decision.verdict === "HOLD") {
|
|
205
|
-
const holdParts = [
|
|
206
|
-
"Action held for operator review. The agent will not be jailed \u2014 please approve or reject this request from the Atbash dashboard, then ask the agent to try again.",
|
|
207
|
-
`Reason: ${decision.reason ?? ""}`
|
|
208
|
-
];
|
|
209
|
-
if (decision.toolCallId) holdParts.push(`Tool Call ID: ${decision.toolCallId}`);
|
|
210
|
-
return toJsonContent({ ...decision, allow: false, reason: holdParts.join("\n") });
|
|
211
|
-
}
|
|
212
|
-
return toJsonContent(decision);
|
|
213
|
-
} catch (error) {
|
|
214
|
-
return toJudgeFailureContent(error);
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
);
|
|
218
|
-
server2.registerTool(
|
|
219
|
-
"atbash_log",
|
|
220
|
-
{
|
|
221
|
-
description: "Submit a tool call for Atbash safety judgment and logging. Returns ALLOW, HOLD, BLOCK, or ERROR.",
|
|
222
|
-
inputSchema: z2.object({
|
|
223
|
-
action: z2.string().describe("Action description"),
|
|
224
|
-
context: z2.string().describe("Action context"),
|
|
225
|
-
tool_name: z2.string().optional().describe("Tool name"),
|
|
226
|
-
tool_args_json: z2.string().optional().describe("Tool arguments JSON")
|
|
227
|
-
})
|
|
228
|
-
},
|
|
229
|
-
async ({ action, context, tool_name, tool_args_json }) => {
|
|
230
|
-
try {
|
|
231
|
-
let parsedArgs = void 0;
|
|
232
|
-
if (tool_args_json) {
|
|
233
|
-
try {
|
|
234
|
-
parsedArgs = JSON.parse(tool_args_json);
|
|
235
|
-
} catch {
|
|
236
|
-
parsedArgs = tool_args_json;
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
const rawDecision = await client2.auditToolCall({
|
|
240
|
-
toolName: tool_name ?? "mcp_log",
|
|
241
|
-
args: parsedArgs ?? { action },
|
|
242
|
-
context: `${action} \u2014 ${context}`
|
|
243
|
-
});
|
|
244
|
-
const decision = denyUnlessExplicitlyAllowed(rawDecision);
|
|
245
|
-
if (decision.verdict === "ERROR") {
|
|
246
|
-
return toJudgeFailureContent(
|
|
247
|
-
new Error(decision.reason ?? "judge returned an error verdict"),
|
|
248
|
-
"atbash_log"
|
|
249
|
-
);
|
|
250
|
-
}
|
|
251
|
-
if (decision.verdict === "HOLD") {
|
|
252
|
-
const holdParts = [
|
|
253
|
-
"Action held for operator review. The agent will not be jailed \u2014 please approve or reject this request from the Atbash dashboard, then ask the agent to try again.",
|
|
254
|
-
`Reason: ${decision.reason ?? ""}`
|
|
255
|
-
];
|
|
256
|
-
if (decision.toolCallId) holdParts.push(`Tool Call ID: ${decision.toolCallId}`);
|
|
257
|
-
return toJsonContent({ ...decision, allow: false, reason: holdParts.join("\n") });
|
|
258
|
-
}
|
|
259
|
-
return toJsonContent(decision);
|
|
260
|
-
} catch (error) {
|
|
261
|
-
return toJudgeFailureContent(error, "atbash_log");
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
);
|
|
265
|
-
server2.registerTool(
|
|
266
|
-
"atbash_check_agent",
|
|
267
|
-
{
|
|
268
|
-
description: "Check whether an agent is registered on the Atbash platform.",
|
|
269
|
-
inputSchema: z2.object({
|
|
270
|
-
pubkey: z2.string().optional().describe("Agent public key, defaults to this server agent")
|
|
271
|
-
})
|
|
272
|
-
},
|
|
273
|
-
async ({ pubkey }) => {
|
|
274
|
-
try {
|
|
275
|
-
const selectedPubkey = pubkey ?? client2.pubkey;
|
|
276
|
-
const registered = await client2.checkAgentExists(selectedPubkey);
|
|
277
|
-
return toJsonContent({ registered, pubkey: selectedPubkey });
|
|
278
|
-
} catch (error) {
|
|
279
|
-
return toErrorContent(error);
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// src/tools/queries.ts
|
|
286
|
-
import { z as z3 } from "zod";
|
|
287
|
-
function defaultMaxCount(value) {
|
|
288
|
-
return value ?? 20;
|
|
289
|
-
}
|
|
290
|
-
function registerQueryTools(server2, client2) {
|
|
291
|
-
server2.registerTool(
|
|
292
|
-
"atbash_get_policy",
|
|
293
|
-
{
|
|
294
|
-
description: "Get an agent policy configuration and jail status.",
|
|
295
|
-
inputSchema: z3.object({
|
|
296
|
-
pubkey: z3.string().optional().describe("Agent public key, defaults to the server agent")
|
|
297
|
-
})
|
|
298
|
-
},
|
|
299
|
-
async ({ pubkey }) => {
|
|
300
|
-
try {
|
|
301
|
-
return toJsonContent(await client2.getAgentPolicy(pubkey ?? client2.pubkey));
|
|
302
|
-
} catch (error) {
|
|
303
|
-
return toErrorContent(error);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
);
|
|
307
|
-
server2.registerTool(
|
|
308
|
-
"atbash_get_agent_detail",
|
|
309
|
-
{
|
|
310
|
-
description: "Get agent metadata for a public key.",
|
|
311
|
-
inputSchema: z3.object({
|
|
312
|
-
pubkey: z3.string().optional().describe("Agent public key, defaults to the server agent")
|
|
313
|
-
})
|
|
314
|
-
},
|
|
315
|
-
async ({ pubkey }) => {
|
|
316
|
-
try {
|
|
317
|
-
return toJsonContent(await client2.getAgentDetail(pubkey ?? client2.pubkey));
|
|
318
|
-
} catch (error) {
|
|
319
|
-
return toErrorContent(error);
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
);
|
|
323
|
-
server2.registerTool(
|
|
324
|
-
"atbash_get_tool_calls",
|
|
325
|
-
{
|
|
326
|
-
description: "List recent tool calls across all agents.",
|
|
327
|
-
inputSchema: z3.object({
|
|
328
|
-
max_count: z3.number().int().positive().optional().describe("Maximum number of records")
|
|
329
|
-
})
|
|
330
|
-
},
|
|
331
|
-
async ({ max_count }) => {
|
|
332
|
-
try {
|
|
333
|
-
return toJsonContent(await client2.getToolCalls(defaultMaxCount(max_count)));
|
|
334
|
-
} catch (error) {
|
|
335
|
-
return toErrorContent(error);
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
);
|
|
339
|
-
server2.registerTool(
|
|
340
|
-
"atbash_get_agent_tool_calls",
|
|
341
|
-
{
|
|
342
|
-
description: "List recent tool calls for one agent.",
|
|
343
|
-
inputSchema: z3.object({
|
|
344
|
-
pubkey: z3.string().optional().describe("Agent public key, defaults to the server agent"),
|
|
345
|
-
max_count: z3.number().int().positive().optional().describe("Maximum number of records")
|
|
346
|
-
})
|
|
347
|
-
},
|
|
348
|
-
async ({ pubkey, max_count }) => {
|
|
349
|
-
try {
|
|
350
|
-
return toJsonContent(
|
|
351
|
-
await client2.getAgentToolCalls(pubkey ?? client2.pubkey, defaultMaxCount(max_count))
|
|
352
|
-
);
|
|
353
|
-
} catch (error) {
|
|
354
|
-
return toErrorContent(error);
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
);
|
|
358
|
-
server2.registerTool(
|
|
359
|
-
"atbash_get_org_tool_calls",
|
|
360
|
-
{
|
|
361
|
-
description: "List recent tool calls for an organization.",
|
|
362
|
-
inputSchema: z3.object({
|
|
363
|
-
org_name: z3.string().describe("Organization name"),
|
|
364
|
-
max_count: z3.number().int().positive().optional().describe("Maximum number of records")
|
|
365
|
-
})
|
|
366
|
-
},
|
|
367
|
-
async ({ org_name, max_count }) => {
|
|
368
|
-
try {
|
|
369
|
-
return toJsonContent(await client2.getOrgToolCalls(org_name, defaultMaxCount(max_count)));
|
|
370
|
-
} catch (error) {
|
|
371
|
-
return toErrorContent(error);
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
);
|
|
375
|
-
server2.registerTool(
|
|
376
|
-
"atbash_get_tool_call_full",
|
|
377
|
-
{
|
|
378
|
-
description: "Get the full detail for a single tool call.",
|
|
379
|
-
inputSchema: z3.object({
|
|
380
|
-
tool_call_id: z3.string().describe("Tool call identifier")
|
|
381
|
-
})
|
|
382
|
-
},
|
|
383
|
-
async ({ tool_call_id }) => {
|
|
384
|
-
try {
|
|
385
|
-
return toJsonContent(await client2.getToolCallFull(tool_call_id));
|
|
386
|
-
} catch (error) {
|
|
387
|
-
return toErrorContent(error);
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
);
|
|
391
|
-
server2.registerTool(
|
|
392
|
-
"atbash_get_tool_call_count",
|
|
393
|
-
{
|
|
394
|
-
description: "Get the total number of tool calls on-chain.",
|
|
395
|
-
inputSchema: z3.object({})
|
|
396
|
-
},
|
|
397
|
-
async () => {
|
|
398
|
-
try {
|
|
399
|
-
return toJsonContent({ count: await client2.getToolCallCount() });
|
|
400
|
-
} catch (error) {
|
|
401
|
-
return toErrorContent(error);
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
);
|
|
405
|
-
server2.registerTool(
|
|
406
|
-
"atbash_get_tier_info",
|
|
407
|
-
{
|
|
408
|
-
description: "Get an organization's tier and enforcement information.",
|
|
409
|
-
inputSchema: z3.object({
|
|
410
|
-
org_name: z3.string().describe("Organization name")
|
|
411
|
-
})
|
|
412
|
-
},
|
|
413
|
-
async ({ org_name }) => {
|
|
414
|
-
try {
|
|
415
|
-
return toJsonContent(await client2.getOrgTierInfo(org_name));
|
|
416
|
-
} catch (error) {
|
|
417
|
-
return toErrorContent(error);
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
);
|
|
421
|
-
server2.registerTool(
|
|
422
|
-
"atbash_get_held_actions",
|
|
423
|
-
{
|
|
424
|
-
description: "List actions currently waiting for operator review.",
|
|
425
|
-
inputSchema: z3.object({
|
|
426
|
-
org_name: z3.string().describe("Organization name"),
|
|
427
|
-
max_count: z3.number().int().positive().optional().describe("Maximum number of records")
|
|
428
|
-
})
|
|
429
|
-
},
|
|
430
|
-
async ({ org_name, max_count }) => {
|
|
431
|
-
try {
|
|
432
|
-
return toJsonContent(
|
|
433
|
-
await client2.getPendingHeldActions(org_name, defaultMaxCount(max_count))
|
|
434
|
-
);
|
|
435
|
-
} catch (error) {
|
|
436
|
-
return toErrorContent(error);
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
);
|
|
440
|
-
server2.registerTool(
|
|
441
|
-
"atbash_get_reviews",
|
|
442
|
-
{
|
|
443
|
-
description: "List completed operator reviews for held actions.",
|
|
444
|
-
inputSchema: z3.object({
|
|
445
|
-
org_name: z3.string().describe("Organization name"),
|
|
446
|
-
max_count: z3.number().int().positive().optional().describe("Maximum number of records")
|
|
447
|
-
})
|
|
448
|
-
},
|
|
449
|
-
async ({ org_name, max_count }) => {
|
|
450
|
-
try {
|
|
451
|
-
return toJsonContent(await client2.getHeldActionReviews(org_name, defaultMaxCount(max_count)));
|
|
452
|
-
} catch (error) {
|
|
453
|
-
return toErrorContent(error);
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
);
|
|
457
|
-
server2.registerTool(
|
|
458
|
-
"atbash_get_safety_stats",
|
|
459
|
-
{
|
|
460
|
-
description: "Get high-level chain-wide safety statistics.",
|
|
461
|
-
inputSchema: z3.object({})
|
|
462
|
-
},
|
|
463
|
-
async () => {
|
|
464
|
-
try {
|
|
465
|
-
return toJsonContent(await client2.getSafetyStats());
|
|
466
|
-
} catch (error) {
|
|
467
|
-
return toErrorContent(error);
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
);
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
// src/tools/status.ts
|
|
474
|
-
import { z as z4 } from "zod";
|
|
475
|
-
function registerStatusTools(server2, client2) {
|
|
476
|
-
server2.registerTool(
|
|
477
|
-
"atbash_judgment_status",
|
|
478
|
-
{
|
|
479
|
-
description: "Poll the status of a previously submitted judgment.",
|
|
480
|
-
inputSchema: z4.object({
|
|
481
|
-
tool_call_id: z4.string().describe("The tool_call_id returned from atbash_judge"),
|
|
482
|
-
agent_pubkey: z4.string().optional().describe("Agent public key, defaults to the server agent")
|
|
483
|
-
})
|
|
484
|
-
},
|
|
485
|
-
async ({ tool_call_id, agent_pubkey }) => {
|
|
486
|
-
try {
|
|
487
|
-
const status = await client2.getJudgmentStatus(tool_call_id, agent_pubkey);
|
|
488
|
-
return toJsonContent(status);
|
|
489
|
-
} catch (error) {
|
|
490
|
-
return toErrorContent(error);
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
);
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
// src/index.ts
|
|
497
|
-
var client = createAtbashClient(process.env, Atbash);
|
|
5
|
+
import { registerSafetyPrompt } from "./prompts/safety.js";
|
|
6
|
+
import { registerPolicyResource } from "./resources/policy.js";
|
|
7
|
+
import { createAtbashClient } from "./startup.js";
|
|
8
|
+
import { registerJudgeTools } from "./tools/judge.js";
|
|
9
|
+
import { registerQueryTools } from "./tools/queries.js";
|
|
10
|
+
import { registerStatusTools } from "./tools/status.js";
|
|
11
|
+
const client = createAtbashClient(process.env, Atbash);
|
|
498
12
|
setupTelemetry({ enabled: true, source: "plugin:mcp" });
|
|
499
13
|
process.once("beforeExit", () => shutdownTelemetry());
|
|
500
14
|
process.once("SIGINT", () => shutdownTelemetry().finally(() => process.exit(0)));
|
|
501
15
|
process.once("SIGTERM", () => shutdownTelemetry().finally(() => process.exit(0)));
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
16
|
+
const server = new McpServer({
|
|
17
|
+
name: "atbash-safety",
|
|
18
|
+
version: "1.0.0",
|
|
505
19
|
});
|
|
506
20
|
registerJudgeTools(server, client);
|
|
507
21
|
registerStatusTools(server, client);
|
|
508
22
|
registerQueryTools(server, client);
|
|
509
23
|
registerPolicyResource(server, client);
|
|
510
24
|
registerSafetyPrompt(server);
|
|
511
|
-
|
|
25
|
+
const transport = new StdioServerTransport();
|
|
512
26
|
await server.connect(transport);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// CRITICAL-2 regression sentinel (see SECURITY-AUDIT-corebreak.md and
|
|
2
|
+
// SECURITY-AUDIT-atbash-ai-migration.md): this package registers
|
|
3
|
+
// atbash_judge/atbash_log as ordinary, optional MCP tools alongside whatever
|
|
4
|
+
// real tools exist elsewhere on the host. It does not wrap, proxy, or gate
|
|
5
|
+
// execution of any other tool — there is no executor in this package at
|
|
6
|
+
// all. A tool call that arrives already-formed (prompt injection, a
|
|
7
|
+
// compromised model, a host replaying a stored function call, or a model
|
|
8
|
+
// that just skips the advisory step) reaches whatever real MCP server
|
|
9
|
+
// executes it with zero interaction with Atbash.
|
|
10
|
+
//
|
|
11
|
+
// This test currently PASSES because that gap still exists. If a future
|
|
12
|
+
// version adds real tool-execution gating, the assertions below should
|
|
13
|
+
// start failing — that's the signal to re-audit CRITICAL-2, not to widen
|
|
14
|
+
// the allowlist to make the test pass again.
|
|
15
|
+
import { describe, it, expect, vi } from "vitest";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { registerJudgeTools } from "./tools/judge.js";
|
|
19
|
+
import { registerQueryTools } from "./tools/queries.js";
|
|
20
|
+
import { registerStatusTools } from "./tools/status.js";
|
|
21
|
+
import { registerPolicyResource } from "./resources/policy.js";
|
|
22
|
+
const KNOWN_SAFE_TOOL_NAMES = [
|
|
23
|
+
"atbash_judge",
|
|
24
|
+
"atbash_log",
|
|
25
|
+
"atbash_check_agent",
|
|
26
|
+
"atbash_get_policy",
|
|
27
|
+
"atbash_get_agent_detail",
|
|
28
|
+
"atbash_get_tool_calls",
|
|
29
|
+
"atbash_get_agent_tool_calls",
|
|
30
|
+
"atbash_get_org_tool_calls",
|
|
31
|
+
"atbash_get_tool_call_full",
|
|
32
|
+
"atbash_get_tool_call_count",
|
|
33
|
+
"atbash_get_tier_info",
|
|
34
|
+
"atbash_get_held_actions",
|
|
35
|
+
"atbash_get_reviews",
|
|
36
|
+
"atbash_get_safety_stats",
|
|
37
|
+
"atbash_judgment_status",
|
|
38
|
+
].sort();
|
|
39
|
+
// A server double that only implements registerTool — any other method call
|
|
40
|
+
// (setRequestHandler, a generic dispatch/proxy API, etc.) throws instead of
|
|
41
|
+
// silently succeeding, so a future executor-style registration can't hide
|
|
42
|
+
// behind an untested server method.
|
|
43
|
+
function makeStrictServer() {
|
|
44
|
+
const registered = [];
|
|
45
|
+
return new Proxy({ registerTool: (name) => registered.push(name) }, {
|
|
46
|
+
get(target, prop, receiver) {
|
|
47
|
+
if (prop === "registered")
|
|
48
|
+
return registered;
|
|
49
|
+
if (prop in target)
|
|
50
|
+
return Reflect.get(target, prop, receiver);
|
|
51
|
+
throw new Error(`unexpected MCP server method called: ${String(prop)}`);
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const fakeClient = {
|
|
56
|
+
auditToolCall: vi.fn(),
|
|
57
|
+
checkAgentExists: vi.fn(),
|
|
58
|
+
pubkey: "x",
|
|
59
|
+
};
|
|
60
|
+
describe("CRITICAL-2 sentinel: atbash-mcp registers no execution gate", () => {
|
|
61
|
+
it("the registered tool set is exactly the known judge/read-only set — nothing else", () => {
|
|
62
|
+
const server = makeStrictServer();
|
|
63
|
+
registerJudgeTools(server, fakeClient);
|
|
64
|
+
registerQueryTools(server, fakeClient);
|
|
65
|
+
registerStatusTools(server, fakeClient);
|
|
66
|
+
registerPolicyResource(server, fakeClient);
|
|
67
|
+
expect([...server.registered].sort()).toEqual(KNOWN_SAFE_TOOL_NAMES);
|
|
68
|
+
});
|
|
69
|
+
it("registerPolicyResource is a documented no-op", () => {
|
|
70
|
+
const server = makeStrictServer();
|
|
71
|
+
// Would throw via the Proxy above if this function called any server
|
|
72
|
+
// method other than nothing at all.
|
|
73
|
+
expect(() => registerPolicyResource(server, fakeClient)).not.toThrow();
|
|
74
|
+
expect(server.registered).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
it("no source file in this package contains an execution/proxy primitive", () => {
|
|
77
|
+
const srcDir = fileURLToPath(new URL(".", import.meta.url));
|
|
78
|
+
const files = [
|
|
79
|
+
"index.ts",
|
|
80
|
+
"tools/judge.ts",
|
|
81
|
+
"tools/queries.ts",
|
|
82
|
+
"tools/status.ts",
|
|
83
|
+
"resources/policy.ts",
|
|
84
|
+
"prompts/safety.ts",
|
|
85
|
+
];
|
|
86
|
+
const executionPatterns = [
|
|
87
|
+
/child_process/,
|
|
88
|
+
/\bexec\(/,
|
|
89
|
+
/\bspawn\(/,
|
|
90
|
+
/new Function\(/,
|
|
91
|
+
/\beval\(/,
|
|
92
|
+
/forwardRequest/,
|
|
93
|
+
/proxyTool/,
|
|
94
|
+
/dispatchTool/,
|
|
95
|
+
];
|
|
96
|
+
for (const file of files) {
|
|
97
|
+
const source = readFileSync(srcDir + file, "utf8");
|
|
98
|
+
for (const pattern of executionPatterns) {
|
|
99
|
+
expect(source, `${file} matched ${pattern}`).not.toMatch(pattern);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export function registerSafetyPrompt(server) {
|
|
3
|
+
const promptServer = server;
|
|
4
|
+
if (!promptServer.registerPrompt) {
|
|
5
|
+
return;
|
|
6
|
+
}
|
|
7
|
+
promptServer.registerPrompt("atbash_safety_check", {
|
|
8
|
+
description: "Prepare a consistent pre-execution Atbash safety review request.",
|
|
9
|
+
argsSchema: {
|
|
10
|
+
action: z.string().describe("Action to evaluate"),
|
|
11
|
+
context: z.string().describe("Why the action is being taken"),
|
|
12
|
+
},
|
|
13
|
+
}, async ({ action, context }) => ({
|
|
14
|
+
messages: [
|
|
15
|
+
{
|
|
16
|
+
role: "user",
|
|
17
|
+
content: {
|
|
18
|
+
type: "text",
|
|
19
|
+
text: [
|
|
20
|
+
"Use the Atbash tools to evaluate the following action before execution.",
|
|
21
|
+
`Action: ${action}`,
|
|
22
|
+
`Context: ${context}`,
|
|
23
|
+
"If the result is HOLD, explain that operator review is required before proceeding.",
|
|
24
|
+
].join("\n"),
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
}));
|
|
29
|
+
}
|
package/dist/startup.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
export function assertMcpSdkCapabilities(sdk) {
|
|
2
|
+
const missing = [
|
|
3
|
+
typeof sdk.fromConfig === "function" ? null : "fromConfig",
|
|
4
|
+
typeof sdk.prototype?.auditToolCall === "function" ? null : "auditToolCall",
|
|
5
|
+
].filter((name) => name !== null);
|
|
6
|
+
if (missing.length > 0) {
|
|
7
|
+
throw new Error(`Incompatible @atbash/sdk: missing required MCP capabilities: ${missing.join(", ")}. ` +
|
|
8
|
+
"Refusing to start without an active safety gate.");
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const DECISION_VERDICTS = [
|
|
12
|
+
"ALLOW",
|
|
13
|
+
"HOLD",
|
|
14
|
+
"BLOCK",
|
|
15
|
+
"ERROR",
|
|
16
|
+
];
|
|
17
|
+
function isDecisionShaped(value) {
|
|
18
|
+
const decision = value;
|
|
19
|
+
return (typeof decision === "object" &&
|
|
20
|
+
decision !== null &&
|
|
21
|
+
typeof decision.allow === "boolean" &&
|
|
22
|
+
DECISION_VERDICTS.includes(decision.verdict));
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* `auditToolCall` collapses the judge's response body into a Decision, and one
|
|
26
|
+
* of its branches is affirmative on absence: a 200 body carrying no `verdict`
|
|
27
|
+
* key becomes `{allow: true, verdict: "ALLOW"}` even when that same body says
|
|
28
|
+
* `action_type: "block"` (@atbash/sdk 0.10.5-dev.0, dist/index.mjs:3589+). The
|
|
29
|
+
* body's own reason is forwarded verbatim, so nothing downstream can tell that
|
|
30
|
+
* synthesized allow apart from a real one — and the shipped MCP configuration
|
|
31
|
+
* talks to an allowlisted endpoint, for which the SDK sets no `verifyPubKey`
|
|
32
|
+
* and therefore skips response-signature verification entirely. Schema drift, a
|
|
33
|
+
* truncated body and a rewriting proxy all land in that branch and read as
|
|
34
|
+
* permission.
|
|
35
|
+
*
|
|
36
|
+
* So bind the allow to the `JudgeResult` the SDK collapsed from: an allow is an
|
|
37
|
+
* allow only when the judge positively said so.
|
|
38
|
+
*
|
|
39
|
+
* Precisely what that inspects, since it is one layer above the HTTP body:
|
|
40
|
+
* `JudgeResult` is built directly from the parsed response
|
|
41
|
+
* (@atbash/sdk 0.10.5-dev.0, dist/index.mjs:3455+), so `actionType` is
|
|
42
|
+
* `String(body.action_type ?? "")` — verbatim, and the check below demands
|
|
43
|
+
* exactly lowercase `"allow"`. `verdict`, however, has been through
|
|
44
|
+
* `normalizeVerdict` (dist/index.mjs:3289+), which upper-cases and aliases:
|
|
45
|
+
* `GREEN`→`ALLOW`, `YELLOW`→`HOLD`, `RED`→`BLOCK`, any other string→`HOLD`,
|
|
46
|
+
* and — the case this guard exists for — `null`/absent→`"No verdict"`. So a
|
|
47
|
+
* judge body of `{"verdict": "green", "action_type": "allow"}` is honoured as
|
|
48
|
+
* an allow, deliberately: `GREEN` is the judge's own alias, not drift. What
|
|
49
|
+
* cannot pass is an allow with no verdict behind it, or one whose
|
|
50
|
+
* `action_type` is anything but `allow`.
|
|
51
|
+
*
|
|
52
|
+
* A non-allow decision is returned with `allow` forced to `false` rather than
|
|
53
|
+
* passed through: the SDK's `fail()` emits `{verdict: "ERROR", allow: true}`
|
|
54
|
+
* when `failClosed` is false (dist/index.mjs:3654+), and this function is
|
|
55
|
+
* exported as a defence-in-depth layer that should not depend on a caller
|
|
56
|
+
* re-checking it. Everything else throws, which the judge tools turn into the
|
|
57
|
+
* explicit verdict-shaped refusal in `toJudgeFailureContent` rather than a
|
|
58
|
+
* silent pass.
|
|
59
|
+
*/
|
|
60
|
+
export function hardenJudgeClient(client) {
|
|
61
|
+
const sdkAudit = client.auditToolCall;
|
|
62
|
+
const sdkJudge = client.judgeAction;
|
|
63
|
+
async function guardedAuditToolCall(input) {
|
|
64
|
+
let wire;
|
|
65
|
+
// `auditToolCall` reads its redaction and org config off `this` and calls
|
|
66
|
+
// `this.judgeAction` exactly once. A per-call prototype shadow captures that
|
|
67
|
+
// raw result without mutating the shared client, so two MCP tool calls in
|
|
68
|
+
// flight at the same time cannot observe each other's verdict.
|
|
69
|
+
const shadow = Object.create(client);
|
|
70
|
+
Object.defineProperty(shadow, "judgeAction", {
|
|
71
|
+
value: async (...args) => {
|
|
72
|
+
const result = await sdkJudge.apply(client, args);
|
|
73
|
+
wire = result;
|
|
74
|
+
return result;
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
const decision = await sdkAudit.call(shadow, input);
|
|
78
|
+
if (!isDecisionShaped(decision)) {
|
|
79
|
+
throw new Error("judge decision is not verdict-shaped and cannot be trusted");
|
|
80
|
+
}
|
|
81
|
+
if (decision.verdict !== "ALLOW" || decision.allow !== true) {
|
|
82
|
+
return { ...decision, allow: false };
|
|
83
|
+
}
|
|
84
|
+
if (wire === undefined) {
|
|
85
|
+
throw new Error("judge returned an allow without a judge response to back it");
|
|
86
|
+
}
|
|
87
|
+
if (wire.verdict !== "ALLOW" || wire.actionType !== "allow") {
|
|
88
|
+
throw new Error("judge response body does not carry an allow " +
|
|
89
|
+
`(verdict ${JSON.stringify(wire.verdict)}, action_type ${JSON.stringify(wire.actionType)})`);
|
|
90
|
+
}
|
|
91
|
+
return decision;
|
|
92
|
+
}
|
|
93
|
+
Object.defineProperty(client, "auditToolCall", {
|
|
94
|
+
value: guardedAuditToolCall,
|
|
95
|
+
writable: true,
|
|
96
|
+
configurable: true,
|
|
97
|
+
});
|
|
98
|
+
return client;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Build the MCP server's Atbash client.
|
|
102
|
+
*
|
|
103
|
+
* `Atbash.fromConfig` is used rather than `new Atbash(privkey, {endpoint})`
|
|
104
|
+
* because only `fromConfig` runs `validateJudgeEndpoint` — the direct
|
|
105
|
+
* constructor accepts any `ATBASH_ENDPOINT` unchecked and with no response
|
|
106
|
+
* signature verification, so an operator-supplied (or environment-injected)
|
|
107
|
+
* judge URL would be trusted outright. The endpoint is passed under `judge`,
|
|
108
|
+
* which is the key `fromConfig` actually reads; passing it at the top level
|
|
109
|
+
* silently drops it and falls back to the default endpoint.
|
|
110
|
+
*/
|
|
111
|
+
export function createAtbashClient(env, sdk) {
|
|
112
|
+
assertMcpSdkCapabilities(sdk);
|
|
113
|
+
const agentKey = env.ATBASH_AGENT_PRIVKEY;
|
|
114
|
+
if (!agentKey) {
|
|
115
|
+
throw new Error("ATBASH_AGENT_PRIVKEY env var is required");
|
|
116
|
+
}
|
|
117
|
+
const endpoint = env.ATBASH_ENDPOINT?.trim();
|
|
118
|
+
const orgName = env.ATBASH_ORG_NAME?.trim();
|
|
119
|
+
return hardenJudgeClient(sdk.fromConfig({
|
|
120
|
+
agentKey,
|
|
121
|
+
...(endpoint ? { judge: { endpoint } } : {}),
|
|
122
|
+
...(orgName ? { orgName } : {}),
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { toErrorContent, toJsonContent, safeErrorMessage } from "../utils.js";
|
|
3
|
+
// MCP is judge-as-a-tool — the host cannot intercept OTHER tools, so
|
|
4
|
+
// enforcement here is advisory and the model itself acts on this payload. A
|
|
5
|
+
// judge outage therefore must not come back as a generic "Error: fetch
|
|
6
|
+
// failed" (easy for the model to shrug off as a broken tool); it comes back
|
|
7
|
+
// as an explicit verdict-shaped refusal that instructs the model not to
|
|
8
|
+
// proceed. Only the safety-gate tools use this — diagnostics keep the
|
|
9
|
+
// generic toErrorContent.
|
|
10
|
+
function toJudgeFailureContent(error, toolName = "atbash_judge") {
|
|
11
|
+
return {
|
|
12
|
+
content: [
|
|
13
|
+
{
|
|
14
|
+
type: "text",
|
|
15
|
+
text: JSON.stringify({
|
|
16
|
+
verdict: "ERROR",
|
|
17
|
+
allow: false,
|
|
18
|
+
reason: `Atbash judge unavailable — treat this as BLOCK and do NOT proceed with the action (fail-closed). ` +
|
|
19
|
+
`Retry ${toolName} once the service is reachable. Cause: ${safeErrorMessage(error)}`,
|
|
20
|
+
}, null, 2),
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
isError: true,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
// Permission requires a positive match: verdict exactly "ALLOW" AND the SDK's
|
|
27
|
+
// boolean allow exactly true. Anything else — HOLD, BLOCK, ERROR, an
|
|
28
|
+
// unrecognised verdict, a missing verdict, or an "ALLOW" that carries
|
|
29
|
+
// allow:false — is handed to the model with allow:false.
|
|
30
|
+
function denyUnlessExplicitlyAllowed(decision) {
|
|
31
|
+
return decision.verdict === "ALLOW" && decision.allow === true
|
|
32
|
+
? decision
|
|
33
|
+
: { ...decision, allow: false };
|
|
34
|
+
}
|
|
35
|
+
export function registerJudgeTools(server, client) {
|
|
36
|
+
server.registerTool("atbash_judge", {
|
|
37
|
+
description: "Submit an action for safety judgment before executing it. Returns ALLOW, HOLD, BLOCK, or ERROR.",
|
|
38
|
+
inputSchema: z.object({
|
|
39
|
+
action: z.string().describe("Plain text description of the action to judge"),
|
|
40
|
+
context: z.string().describe("Why this action is being taken"),
|
|
41
|
+
tool_name: z.string().optional().describe("Name of the tool being called"),
|
|
42
|
+
tool_args_json: z.string().optional().describe("JSON string of tool arguments"),
|
|
43
|
+
}),
|
|
44
|
+
}, async ({ action, context, tool_name, tool_args_json }) => {
|
|
45
|
+
try {
|
|
46
|
+
let parsedArgs = undefined;
|
|
47
|
+
if (tool_args_json) {
|
|
48
|
+
try {
|
|
49
|
+
parsedArgs = JSON.parse(tool_args_json);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
parsedArgs = tool_args_json;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const rawDecision = await client.auditToolCall({
|
|
56
|
+
toolName: tool_name ?? "mcp_judge",
|
|
57
|
+
args: parsedArgs ?? { action },
|
|
58
|
+
context: `${action} — ${context}`,
|
|
59
|
+
});
|
|
60
|
+
const decision = denyUnlessExplicitlyAllowed(rawDecision);
|
|
61
|
+
if (decision.verdict === "ERROR") {
|
|
62
|
+
return toJudgeFailureContent(new Error(decision.reason ?? "judge returned an error verdict"));
|
|
63
|
+
}
|
|
64
|
+
if (decision.verdict === "HOLD") {
|
|
65
|
+
const holdParts = [
|
|
66
|
+
"Action held for operator review. The agent will not be jailed — please approve or reject this request from the Atbash dashboard, then ask the agent to try again.",
|
|
67
|
+
`Reason: ${decision.reason ?? ""}`,
|
|
68
|
+
];
|
|
69
|
+
if (decision.toolCallId)
|
|
70
|
+
holdParts.push(`Tool Call ID: ${decision.toolCallId}`);
|
|
71
|
+
return toJsonContent({ ...decision, allow: false, reason: holdParts.join("\n") });
|
|
72
|
+
}
|
|
73
|
+
return toJsonContent(decision);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
return toJudgeFailureContent(error);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
server.registerTool("atbash_log", {
|
|
80
|
+
description: "Submit a tool call for Atbash safety judgment and logging. Returns ALLOW, HOLD, BLOCK, or ERROR.",
|
|
81
|
+
inputSchema: z.object({
|
|
82
|
+
action: z.string().describe("Action description"),
|
|
83
|
+
context: z.string().describe("Action context"),
|
|
84
|
+
tool_name: z.string().optional().describe("Tool name"),
|
|
85
|
+
tool_args_json: z.string().optional().describe("Tool arguments JSON"),
|
|
86
|
+
}),
|
|
87
|
+
}, async ({ action, context, tool_name, tool_args_json }) => {
|
|
88
|
+
try {
|
|
89
|
+
let parsedArgs = undefined;
|
|
90
|
+
if (tool_args_json) {
|
|
91
|
+
try {
|
|
92
|
+
parsedArgs = JSON.parse(tool_args_json);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
parsedArgs = tool_args_json;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const rawDecision = await client.auditToolCall({
|
|
99
|
+
toolName: tool_name ?? "mcp_log",
|
|
100
|
+
args: parsedArgs ?? { action },
|
|
101
|
+
context: `${action} — ${context}`,
|
|
102
|
+
});
|
|
103
|
+
const decision = denyUnlessExplicitlyAllowed(rawDecision);
|
|
104
|
+
if (decision.verdict === "ERROR") {
|
|
105
|
+
return toJudgeFailureContent(new Error(decision.reason ?? "judge returned an error verdict"), "atbash_log");
|
|
106
|
+
}
|
|
107
|
+
if (decision.verdict === "HOLD") {
|
|
108
|
+
const holdParts = [
|
|
109
|
+
"Action held for operator review. The agent will not be jailed — please approve or reject this request from the Atbash dashboard, then ask the agent to try again.",
|
|
110
|
+
`Reason: ${decision.reason ?? ""}`,
|
|
111
|
+
];
|
|
112
|
+
if (decision.toolCallId)
|
|
113
|
+
holdParts.push(`Tool Call ID: ${decision.toolCallId}`);
|
|
114
|
+
return toJsonContent({ ...decision, allow: false, reason: holdParts.join("\n") });
|
|
115
|
+
}
|
|
116
|
+
return toJsonContent(decision);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
return toJudgeFailureContent(error, "atbash_log");
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
server.registerTool("atbash_check_agent", {
|
|
123
|
+
description: "Check whether an agent is registered on the Atbash platform.",
|
|
124
|
+
inputSchema: z.object({
|
|
125
|
+
pubkey: z.string().optional().describe("Agent public key, defaults to this server agent"),
|
|
126
|
+
}),
|
|
127
|
+
}, async ({ pubkey }) => {
|
|
128
|
+
try {
|
|
129
|
+
const selectedPubkey = pubkey ?? client.pubkey;
|
|
130
|
+
const registered = await client.checkAgentExists(selectedPubkey);
|
|
131
|
+
return toJsonContent({ registered, pubkey: selectedPubkey });
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
return toErrorContent(error);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { registerJudgeTools } from "./judge.js";
|
|
3
|
+
function makeFakeServer() {
|
|
4
|
+
const handlers = new Map();
|
|
5
|
+
return {
|
|
6
|
+
handlers,
|
|
7
|
+
registerTool: vi.fn((name, _config, handler) => {
|
|
8
|
+
handlers.set(name, handler);
|
|
9
|
+
}),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
function makeClient(decision) {
|
|
13
|
+
return {
|
|
14
|
+
auditToolCall: vi.fn().mockResolvedValue(decision),
|
|
15
|
+
checkAgentExists: vi.fn(),
|
|
16
|
+
pubkey: "server-agent-pubkey",
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
describe("registerJudgeTools", () => {
|
|
20
|
+
it("registers exactly atbash_judge, atbash_log, atbash_check_agent", () => {
|
|
21
|
+
const server = makeFakeServer();
|
|
22
|
+
registerJudgeTools(server, makeClient({ verdict: "ALLOW" }));
|
|
23
|
+
expect([...server.handlers.keys()].sort()).toEqual([
|
|
24
|
+
"atbash_check_agent",
|
|
25
|
+
"atbash_judge",
|
|
26
|
+
"atbash_log",
|
|
27
|
+
]);
|
|
28
|
+
});
|
|
29
|
+
describe("atbash_judge", () => {
|
|
30
|
+
it("passes ALLOW through unchanged", async () => {
|
|
31
|
+
const decision = { verdict: "ALLOW", allow: true, reason: "fine", toolCallId: "tc-1" };
|
|
32
|
+
const client = makeClient(decision);
|
|
33
|
+
const server = makeFakeServer();
|
|
34
|
+
registerJudgeTools(server, client);
|
|
35
|
+
const result = await server.handlers.get("atbash_judge")({
|
|
36
|
+
action: "wire $5000",
|
|
37
|
+
context: "customer request",
|
|
38
|
+
tool_name: "wire_transfer",
|
|
39
|
+
tool_args_json: JSON.stringify({ to: "0xabc" }),
|
|
40
|
+
});
|
|
41
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
42
|
+
expect(parsed).toEqual(decision);
|
|
43
|
+
expect(client.auditToolCall).toHaveBeenCalledWith({
|
|
44
|
+
toolName: "wire_transfer",
|
|
45
|
+
args: { to: "0xabc" },
|
|
46
|
+
context: "wire $5000 — customer request",
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
it("reformats HOLD with allow:false and the tool_call_id reference", async () => {
|
|
50
|
+
const decision = {
|
|
51
|
+
verdict: "HOLD",
|
|
52
|
+
reason: "needs operator review",
|
|
53
|
+
toolCallId: "tc-2",
|
|
54
|
+
};
|
|
55
|
+
const server = makeFakeServer();
|
|
56
|
+
registerJudgeTools(server, makeClient(decision));
|
|
57
|
+
const result = await server.handlers.get("atbash_judge")({
|
|
58
|
+
action: "wire $999999",
|
|
59
|
+
context: "customer request",
|
|
60
|
+
});
|
|
61
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
62
|
+
expect(parsed.verdict).toBe("HOLD");
|
|
63
|
+
expect(parsed.allow).toBe(false);
|
|
64
|
+
expect(parsed.reason).toContain("held for operator review");
|
|
65
|
+
expect(parsed.reason).toContain("Tool Call ID: tc-2");
|
|
66
|
+
});
|
|
67
|
+
it("falls back to a bare {action} payload when tool_args_json is absent", async () => {
|
|
68
|
+
const client = makeClient({ verdict: "ALLOW" });
|
|
69
|
+
const server = makeFakeServer();
|
|
70
|
+
registerJudgeTools(server, client);
|
|
71
|
+
await server.handlers.get("atbash_judge")({ action: "list files", context: "audit" });
|
|
72
|
+
expect(client.auditToolCall).toHaveBeenCalledWith({
|
|
73
|
+
toolName: "mcp_judge",
|
|
74
|
+
args: { action: "list files" },
|
|
75
|
+
context: "list files — audit",
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
it("returns an error content block instead of throwing when the judge is unavailable", async () => {
|
|
79
|
+
const client = {
|
|
80
|
+
auditToolCall: vi.fn().mockRejectedValue(new Error("judge unreachable")),
|
|
81
|
+
};
|
|
82
|
+
const server = makeFakeServer();
|
|
83
|
+
registerJudgeTools(server, client);
|
|
84
|
+
const result = await server.handlers.get("atbash_judge")({
|
|
85
|
+
action: "x",
|
|
86
|
+
context: "y",
|
|
87
|
+
});
|
|
88
|
+
expect(result.isError).toBe(true);
|
|
89
|
+
expect(result.content[0].text).toContain("judge unreachable");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
describe("atbash_check_agent", () => {
|
|
93
|
+
it("defaults to the server agent's own pubkey when none is given", async () => {
|
|
94
|
+
const client = makeClient({ verdict: "ALLOW" });
|
|
95
|
+
client.checkAgentExists.mockResolvedValue(true);
|
|
96
|
+
const server = makeFakeServer();
|
|
97
|
+
registerJudgeTools(server, client);
|
|
98
|
+
const result = await server.handlers.get("atbash_check_agent")({});
|
|
99
|
+
expect(client.checkAgentExists).toHaveBeenCalledWith("server-agent-pubkey");
|
|
100
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
101
|
+
expect(parsed).toEqual({ registered: true, pubkey: "server-agent-pubkey" });
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { toErrorContent, toJsonContent } from "../utils.js";
|
|
3
|
+
function defaultMaxCount(value) {
|
|
4
|
+
return value ?? 20;
|
|
5
|
+
}
|
|
6
|
+
export function registerQueryTools(server, client) {
|
|
7
|
+
server.registerTool("atbash_get_policy", {
|
|
8
|
+
description: "Get an agent policy configuration and jail status.",
|
|
9
|
+
inputSchema: z.object({
|
|
10
|
+
pubkey: z.string().optional().describe("Agent public key, defaults to the server agent"),
|
|
11
|
+
}),
|
|
12
|
+
}, async ({ pubkey }) => {
|
|
13
|
+
try {
|
|
14
|
+
return toJsonContent(await client.getAgentPolicy(pubkey ?? client.pubkey));
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
return toErrorContent(error);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
server.registerTool("atbash_get_agent_detail", {
|
|
21
|
+
description: "Get agent metadata for a public key.",
|
|
22
|
+
inputSchema: z.object({
|
|
23
|
+
pubkey: z.string().optional().describe("Agent public key, defaults to the server agent"),
|
|
24
|
+
}),
|
|
25
|
+
}, async ({ pubkey }) => {
|
|
26
|
+
try {
|
|
27
|
+
return toJsonContent(await client.getAgentDetail(pubkey ?? client.pubkey));
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
return toErrorContent(error);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
server.registerTool("atbash_get_tool_calls", {
|
|
34
|
+
description: "List recent tool calls across all agents.",
|
|
35
|
+
inputSchema: z.object({
|
|
36
|
+
max_count: z.number().int().positive().optional().describe("Maximum number of records"),
|
|
37
|
+
}),
|
|
38
|
+
}, async ({ max_count }) => {
|
|
39
|
+
try {
|
|
40
|
+
return toJsonContent(await client.getToolCalls(defaultMaxCount(max_count)));
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
return toErrorContent(error);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
server.registerTool("atbash_get_agent_tool_calls", {
|
|
47
|
+
description: "List recent tool calls for one agent.",
|
|
48
|
+
inputSchema: z.object({
|
|
49
|
+
pubkey: z.string().optional().describe("Agent public key, defaults to the server agent"),
|
|
50
|
+
max_count: z.number().int().positive().optional().describe("Maximum number of records"),
|
|
51
|
+
}),
|
|
52
|
+
}, async ({ pubkey, max_count }) => {
|
|
53
|
+
try {
|
|
54
|
+
return toJsonContent(await client.getAgentToolCalls(pubkey ?? client.pubkey, defaultMaxCount(max_count)));
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
return toErrorContent(error);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
server.registerTool("atbash_get_org_tool_calls", {
|
|
61
|
+
description: "List recent tool calls for an organization.",
|
|
62
|
+
inputSchema: z.object({
|
|
63
|
+
org_name: z.string().describe("Organization name"),
|
|
64
|
+
max_count: z.number().int().positive().optional().describe("Maximum number of records"),
|
|
65
|
+
}),
|
|
66
|
+
}, async ({ org_name, max_count }) => {
|
|
67
|
+
try {
|
|
68
|
+
return toJsonContent(await client.getOrgToolCalls(org_name, defaultMaxCount(max_count)));
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
return toErrorContent(error);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
server.registerTool("atbash_get_tool_call_full", {
|
|
75
|
+
description: "Get the full detail for a single tool call.",
|
|
76
|
+
inputSchema: z.object({
|
|
77
|
+
tool_call_id: z.string().describe("Tool call identifier"),
|
|
78
|
+
}),
|
|
79
|
+
}, async ({ tool_call_id }) => {
|
|
80
|
+
try {
|
|
81
|
+
return toJsonContent(await client.getToolCallFull(tool_call_id));
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
return toErrorContent(error);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
server.registerTool("atbash_get_tool_call_count", {
|
|
88
|
+
description: "Get the total number of tool calls on-chain.",
|
|
89
|
+
inputSchema: z.object({}),
|
|
90
|
+
}, async () => {
|
|
91
|
+
try {
|
|
92
|
+
return toJsonContent({ count: await client.getToolCallCount() });
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
return toErrorContent(error);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
server.registerTool("atbash_get_tier_info", {
|
|
99
|
+
description: "Get an organization's tier and enforcement information.",
|
|
100
|
+
inputSchema: z.object({
|
|
101
|
+
org_name: z.string().describe("Organization name"),
|
|
102
|
+
}),
|
|
103
|
+
}, async ({ org_name }) => {
|
|
104
|
+
try {
|
|
105
|
+
return toJsonContent(await client.getOrgTierInfo(org_name));
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
return toErrorContent(error);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
server.registerTool("atbash_get_held_actions", {
|
|
112
|
+
description: "List actions currently waiting for operator review.",
|
|
113
|
+
inputSchema: z.object({
|
|
114
|
+
org_name: z.string().describe("Organization name"),
|
|
115
|
+
max_count: z.number().int().positive().optional().describe("Maximum number of records"),
|
|
116
|
+
}),
|
|
117
|
+
}, async ({ org_name, max_count }) => {
|
|
118
|
+
try {
|
|
119
|
+
return toJsonContent(await client.getPendingHeldActions(org_name, defaultMaxCount(max_count)));
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
return toErrorContent(error);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
server.registerTool("atbash_get_reviews", {
|
|
126
|
+
description: "List completed operator reviews for held actions.",
|
|
127
|
+
inputSchema: z.object({
|
|
128
|
+
org_name: z.string().describe("Organization name"),
|
|
129
|
+
max_count: z.number().int().positive().optional().describe("Maximum number of records"),
|
|
130
|
+
}),
|
|
131
|
+
}, async ({ org_name, max_count }) => {
|
|
132
|
+
try {
|
|
133
|
+
return toJsonContent(await client.getHeldActionReviews(org_name, defaultMaxCount(max_count)));
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
return toErrorContent(error);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
server.registerTool("atbash_get_safety_stats", {
|
|
140
|
+
description: "Get high-level chain-wide safety statistics.",
|
|
141
|
+
inputSchema: z.object({}),
|
|
142
|
+
}, async () => {
|
|
143
|
+
try {
|
|
144
|
+
return toJsonContent(await client.getSafetyStats());
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
return toErrorContent(error);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { toErrorContent, toJsonContent } from "../utils.js";
|
|
3
|
+
export function registerStatusTools(server, client) {
|
|
4
|
+
server.registerTool("atbash_judgment_status", {
|
|
5
|
+
description: "Poll the status of a previously submitted judgment.",
|
|
6
|
+
inputSchema: z.object({
|
|
7
|
+
tool_call_id: z.string().describe("The tool_call_id returned from atbash_judge"),
|
|
8
|
+
agent_pubkey: z
|
|
9
|
+
.string()
|
|
10
|
+
.optional()
|
|
11
|
+
.describe("Agent public key, defaults to the server agent"),
|
|
12
|
+
}),
|
|
13
|
+
}, async ({ tool_call_id, agent_pubkey }) => {
|
|
14
|
+
try {
|
|
15
|
+
const status = await client.getJudgmentStatus(tool_call_id, agent_pubkey);
|
|
16
|
+
return toJsonContent(status);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
return toErrorContent(error);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
}
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function safeErrorMessage(error) {
|
|
2
|
+
return error instanceof Error ? error.message : String(error);
|
|
3
|
+
}
|
|
4
|
+
export function toJsonContent(value) {
|
|
5
|
+
return {
|
|
6
|
+
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
export function toErrorContent(error) {
|
|
10
|
+
return {
|
|
11
|
+
content: [{ type: "text", text: `Error: ${safeErrorMessage(error)}` }],
|
|
12
|
+
isError: true,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export function truncateText(value, max = 500) {
|
|
16
|
+
return value.length <= max ? value : `${value.slice(0, max - 3)}...`;
|
|
17
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atbash/atbash-mcp",
|
|
3
|
-
"version": "0.0.12-dev.
|
|
3
|
+
"version": "0.0.12-dev.2",
|
|
4
4
|
"description": "Atbash safety judge exposed as a standalone MCP server",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"release:dev": "node scripts/release.mjs --channel dev"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@atbash/sdk": "0.
|
|
51
|
+
"@atbash/sdk": "0.13.3-dev.0",
|
|
52
52
|
"@modelcontextprotocol/sdk": "^1.12.3",
|
|
53
53
|
"zod": "^3.25.76"
|
|
54
54
|
},
|