@atbash/atbash-langgraph 0.0.13 → 0.0.15-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/LICENSE ADDED
@@ -0,0 +1,14 @@
1
+ Atbash CLI — Proprietary Software License
2
+
3
+ Copyright © 2026 Atbash. All rights reserved.
4
+
5
+ The full license is available at https://atbash.ai/license
6
+
7
+ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
8
+
9
+ Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
10
+ Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
11
+
12
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
13
+
14
+ The views and conclusions contained in the software and documentation are those of the authors and should not be interpreted as representing official policies, either expressed or implied, of Atbash.
package/README.md CHANGED
@@ -45,6 +45,19 @@ addAtbashSafety(builder, {
45
45
  const app = builder.compile({ checkpointer: new MemorySaver() });
46
46
  ```
47
47
 
48
+ `addAtbashSafety` rewrites every route into your tools node so it passes through
49
+ `atbash_guard` first — a router that returns `"tools"`, a plain `agent -> tools` edge, or
50
+ nothing at all. A checkpointer is required: `HOLD` pauses the run with `interrupt()`, and a
51
+ hold that cannot pause is blocked.
52
+
53
+ **Call order does not matter.** Routes you add *after* `addAtbashSafety` are rewritten too:
54
+ the helper takes over `addEdge()` and `addConditionalEdges()` on your builder for the rest
55
+ of the wiring. A route it cannot rewrite — a `Command()` destination declared with
56
+ `addNode(..., { ends: ["tools"] })`, or a direct write to the builder's internals — makes
57
+ `compile()` throw rather than run unjudged. The one case that fails at run time instead is a
58
+ branch returning `new Send("tools", ...)`, which carries a payload that cannot be re-pointed;
59
+ send it to `"atbash_guard"` instead.
60
+
48
61
  ## API
49
62
 
50
63
  ### `addAtbashSafety(builder, opts)`
@@ -61,7 +74,11 @@ Assumes your graph has nodes named `agent` and `tools`. If your layout differs,
61
74
 
62
75
  ### `createGuardNode(opts)`
63
76
 
64
- Creates the pre-tool safety node. Calls `client.auditToolCall()` for every tool call in the last AI message.
77
+ Creates the pre-tool safety node. Calls `client.auditToolCall()` for every tool call in the last AI
78
+ message that has no answering `ToolMessage` — the same calls a prebuilt `ToolNode` would run, so the
79
+ guard and the executor cannot disagree about what is about to execute. Memory reads and writes are
80
+ handed to the SDK's memory guard first; only a decision that positively allows takes a call out of
81
+ the batch audit.
65
82
 
66
83
  Returns a node function that writes `atbashVerdict`, `atbashReason`, `atbashToolCallId` to state.
67
84
 
@@ -88,10 +105,12 @@ Optional: creates an `atbash_safety_check` LangChain tool for direct LLM access
88
105
 
89
106
  | Verdict | Meaning | Graph Behavior |
90
107
  |---|---|---|
91
- | `ALLOW` | Safe to proceed | Routes to `tools` node |
108
+ | `ALLOW` | Safe to proceed, and `allow` is not `false` | Routes to `tools` node |
109
+ | `SKIP` | Nothing pending to judge | Routes to `END` — never to `tools` |
92
110
  | `HOLD` | Needs human review | Graph interrupts; resume with `Command({ resume: "approve" })` |
93
111
  | `BLOCK` | Policy violation | Injects blocked `ToolMessage`; routes back to `agent` |
94
112
  | `ERROR` | Judge unreachable | Treated as `BLOCK` — fail closed |
113
+ | anything else | Unrecognised, malformed, or tampered decision | Treated as `BLOCK` — fail closed |
95
114
 
96
115
  ## HOLD / Resume Pattern
97
116
 
@@ -111,6 +130,17 @@ if (isInterrupted(result)) {
111
130
  }
112
131
  ```
113
132
 
133
+ The resume value is whatever you pass, so it is not treated as approval. The guard node
134
+ re-executes from the top on resume: it re-submits the held action with `auditToolCall()` and
135
+ releases it only on a positively matched `ALLOW` — either from that re-audit, or from
136
+ `getJudgmentStatus()` on the judgment the re-audit returned. A still-pending hold, any other
137
+ verdict, or an unreachable judge keeps it held.
138
+
139
+ The `getJudgmentStatus()` half only fires if the backend returns the same `toolCallId` for a
140
+ resubmitted action; if it mints a fresh id per submission, release happens entirely through the
141
+ re-audit. Both routes require an explicit `ALLOW`, so either way approving from the dashboard
142
+ and asking the agent to try again is the supported flow.
143
+
114
144
  ## Environment Variables
115
145
 
116
146
  | Variable | Required | Description |
package/dist/index.d.ts CHANGED
@@ -30,13 +30,13 @@ interface AtbashSafetyOptions {
30
30
  endpoint?: string;
31
31
  toolsNode?: string;
32
32
  agentNode?: string;
33
- /** Agent workspace directory `~` is expanded. Defaults to `process.cwd()`. */
33
+ /** Agent workspace directory - `~` is expanded. Defaults to `process.cwd()`. */
34
34
  workspaceDir?: string;
35
35
  /** Explicit MEMORY.md path. Overrides `workspaceDir/MEMORY.md`. */
36
36
  memoryFilePath?: string;
37
37
  /** How long (ms) to trust the local pointer between chain checks. Default 30000. */
38
38
  memorySyncTTLMs?: number;
39
- /** Block memory reads when a rolled-back version scores below this (110). Default 1 = warn only. */
39
+ /** Block memory reads when a rolled-back version scores below this (1-10). Default 1 = warn only. */
40
40
  memoryRollbackMinScore?: number;
41
41
  /** Custom memory path patterns. Defaults to SDK built-ins. */
42
42
  memoryPathPatterns?: string[];
@@ -46,7 +46,7 @@ interface AtbashSafetyOptions {
46
46
  judgeVerifyPubKey?: string;
47
47
  /** Organization name for chain resolution (public vs private chain). */
48
48
  orgName?: string;
49
- /** false = monitor mode: log but never block. Default true. */
49
+ /** false = monitor mode for the memory guard: log but never block. Default true. */
50
50
  enforce?: boolean;
51
51
  debug?: boolean;
52
52
  }
package/dist/index.js CHANGED
@@ -21,16 +21,38 @@ var AtbashStateAnnotation = Annotation.Root({
21
21
  });
22
22
 
23
23
  // src/nodes/guardNode.ts
24
- import { MemoryIntegrityError } from "@atbash/sdk";
25
- import { ToolMessage } from "@langchain/core/messages";
26
- import { isGraphBubbleUp } from "@langchain/langgraph";
24
+ import {
25
+ MemoryIntegrityError
26
+ } from "@atbash/sdk";
27
+ import { ToolMessage, isAIMessage } from "@langchain/core/messages";
28
+ import { interrupt, isGraphBubbleUp } from "@langchain/langgraph";
29
+ function pendingToolCalls(messages) {
30
+ const answered = new Set(
31
+ messages.filter((message) => message?.getType?.() === "tool").map((message) => message.tool_call_id)
32
+ );
33
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
34
+ const message = messages[index];
35
+ if (!message || !isAIMessage(message)) continue;
36
+ const calls = message.tool_calls ?? [];
37
+ return calls.filter((call) => call.id == null || !answered.has(call.id));
38
+ }
39
+ return [];
40
+ }
41
+ async function approvedOnResume(client, toolCallId) {
42
+ if (!toolCallId) return false;
43
+ try {
44
+ const status = await client.getJudgmentStatus(toolCallId);
45
+ return status.status === "answered" && status.verdict === "ALLOW";
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
27
50
  function createGuardNode(opts) {
28
51
  return async (state) => {
29
- const lastMessage = state.messages[state.messages.length - 1];
30
- const toolCalls = lastMessage?.tool_calls ?? [];
52
+ const toolCalls = pendingToolCalls(state.messages);
31
53
  if (toolCalls.length === 0) {
32
54
  return {
33
- atbashVerdict: "ALLOW",
55
+ atbashVerdict: "SKIP",
34
56
  atbashReason: "No tool calls detected"
35
57
  };
36
58
  }
@@ -50,7 +72,7 @@ function createGuardNode(opts) {
50
72
  messages: toolCalls.map(
51
73
  (toolCall) => new ToolMessage({
52
74
  tool_call_id: toolCall.id,
53
- content: toolCall.id === tc.id ? `Memory guard error: ${reason}` : "Blocked \u2014 memory safety check failed for another tool call"
75
+ content: toolCall.id === tc.id ? `Memory guard error: ${reason}` : "Blocked - memory safety check failed for another tool call"
54
76
  })
55
77
  ),
56
78
  atbashVerdict: verdict,
@@ -60,16 +82,31 @@ function createGuardNode(opts) {
60
82
  };
61
83
  }
62
84
  if (memDecision !== null) {
63
- if (memDecision.block) {
85
+ const decided = memDecision;
86
+ if (decided.block) {
64
87
  return {
65
88
  messages: toolCalls.map(
66
89
  (toolCall) => new ToolMessage({
67
90
  tool_call_id: toolCall.id,
68
- content: toolCall.id === tc.id ? `Memory write blocked by Atbash: ${memDecision.blockReason ?? ""}` : "Blocked \u2014 another tool call was blocked by memory safety"
91
+ content: toolCall.id === tc.id ? `Memory write blocked by Atbash: ${decided.blockReason ?? ""}` : "Blocked - another tool call was blocked by memory safety"
69
92
  })
70
93
  ),
71
94
  atbashVerdict: "BLOCK",
72
- atbashReason: memDecision.blockReason ?? "memory write blocked",
95
+ atbashReason: decided.blockReason ?? "memory write blocked",
96
+ atbashToolCallId: null,
97
+ atbashConfidence: null
98
+ };
99
+ }
100
+ if (decided.allow !== true) {
101
+ return {
102
+ messages: toolCalls.map(
103
+ (toolCall) => new ToolMessage({
104
+ tool_call_id: toolCall.id,
105
+ content: toolCall.id === tc.id ? `BLOCKED by Atbash: unusable memory guard decision: ${decided.reason ?? "no reason"}` : "Blocked - memory safety returned an unusable decision for another tool call"
106
+ })
107
+ ),
108
+ atbashVerdict: "BLOCK",
109
+ atbashReason: decided.reason ?? "unusable memory guard decision",
73
110
  atbashToolCallId: null,
74
111
  atbashConfidence: null
75
112
  };
@@ -110,11 +147,26 @@ function createGuardNode(opts) {
110
147
  }
111
148
  if (decision.verdict === "HOLD") {
112
149
  const holdParts = [
113
- "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.",
150
+ "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.",
114
151
  `Reason: ${decision.reason ?? ""}`
115
152
  ];
116
153
  if (decision.toolCallId) holdParts.push(`Tool Call ID: ${decision.toolCallId}`);
117
154
  const holdMessage = holdParts.join("\n");
155
+ interrupt({
156
+ verdict: "HOLD",
157
+ reason: decision.reason ?? "held for review",
158
+ toolCallId: decision.toolCallId ?? null,
159
+ message: holdMessage,
160
+ toolCalls: toolCalls.map(({ id, name, args }) => ({ id, name, args }))
161
+ });
162
+ if (await approvedOnResume(opts.client, decision.toolCallId)) {
163
+ return {
164
+ atbashVerdict: "ALLOW",
165
+ atbashReason: decision.reason,
166
+ atbashToolCallId: decision.toolCallId,
167
+ atbashConfidence: null
168
+ };
169
+ }
118
170
  return {
119
171
  messages: toolCalls.map(
120
172
  (toolCall) => new ToolMessage({
@@ -128,9 +180,26 @@ function createGuardNode(opts) {
128
180
  atbashConfidence: null
129
181
  };
130
182
  }
183
+ if (decision.verdict === "ALLOW" && decision.allow !== false) {
184
+ return {
185
+ atbashVerdict: "ALLOW",
186
+ atbashReason: decision.reason,
187
+ atbashToolCallId: decision.toolCallId,
188
+ atbashConfidence: null
189
+ };
190
+ }
191
+ const unusable = `BLOCKED by Atbash: unusable decision ${String(
192
+ decision.verdict
193
+ )}: ${decision.reason ?? "no reason"}`;
131
194
  return {
132
- atbashVerdict: "ALLOW",
133
- atbashReason: decision.reason,
195
+ messages: toolCalls.map(
196
+ (toolCall) => new ToolMessage({
197
+ tool_call_id: toolCall.id,
198
+ content: unusable
199
+ })
200
+ ),
201
+ atbashVerdict: "BLOCK",
202
+ atbashReason: decision.reason ?? "unusable Atbash decision",
134
203
  atbashToolCallId: decision.toolCallId,
135
204
  atbashConfidence: null
136
205
  };
@@ -185,9 +254,101 @@ import {
185
254
  shutdownTelemetry
186
255
  } from "@atbash/sdk";
187
256
  import { homedir } from "os";
257
+ import { RunnableLambda } from "@langchain/core/runnables";
258
+ import { END } from "@langchain/langgraph";
188
259
  function expandHome(p) {
189
260
  return p.replace(/^~(?=\/|$)/, homedir());
190
261
  }
262
+ var GUARD_NODE = "atbash_guard";
263
+ var AUDIT_NODE = "atbash_audit";
264
+ var GUARDED_BRANCH = /* @__PURE__ */ Symbol.for("atbash.langgraph.guardedBranch");
265
+ var SEALED = /* @__PURE__ */ Symbol.for("atbash.langgraph.sealed");
266
+ function rewriteDestination(destination, toolsNode, hasEnds) {
267
+ if (destination && typeof destination === "object" && destination.node === toolsNode) {
268
+ throw new Error(
269
+ `Atbash cannot guard a Send() to "${toolsNode}" - send to "${GUARD_NODE}" instead.`
270
+ );
271
+ }
272
+ if (!hasEnds && destination === toolsNode) return GUARD_NODE;
273
+ return destination;
274
+ }
275
+ function wrapBranchPath(branch, toolsNode) {
276
+ const original = branch.path;
277
+ const hasEnds = branch.ends != null;
278
+ return RunnableLambda.from(async (input, config) => {
279
+ const result = await original.invoke(input, config);
280
+ return Array.isArray(result) ? result.map((destination) => rewriteDestination(destination, toolsNode, hasEnds)) : rewriteDestination(result, toolsNode, hasEnds);
281
+ });
282
+ }
283
+ function hasEdge(graph, from, to) {
284
+ return [...graph.edges].some(([start, end]) => start === from && end === to);
285
+ }
286
+ function isGuardedBranch(branch) {
287
+ return branch[GUARDED_BRANCH] === true;
288
+ }
289
+ function routeToolsThroughGuard(graph, toolsNode) {
290
+ for (const edge of [...graph.edges]) {
291
+ const [from, to] = edge;
292
+ if (to !== toolsNode || from === GUARD_NODE) continue;
293
+ graph.edges.delete(edge);
294
+ if (!hasEdge(graph, from, GUARD_NODE)) graph.edges.add([from, GUARD_NODE]);
295
+ }
296
+ for (const [source, branches] of Object.entries(graph.branches)) {
297
+ if (source === GUARD_NODE) continue;
298
+ for (const branch of Object.values(branches)) {
299
+ for (const [key, destination] of Object.entries(branch.ends ?? {})) {
300
+ if (destination === toolsNode) branch.ends[key] = GUARD_NODE;
301
+ }
302
+ if (isGuardedBranch(branch)) continue;
303
+ branch.path = wrapBranchPath(branch, toolsNode);
304
+ Object.defineProperty(branch, GUARDED_BRANCH, { value: true });
305
+ }
306
+ }
307
+ }
308
+ function assertGuardOwnsToolsNode(graph, toolsNode) {
309
+ const refuse = (source, kind) => {
310
+ throw new Error(
311
+ `Atbash: ${kind} from "${source}" reaches "${toolsNode}" directly, bypassing "${GUARD_NODE}" - route it to "${GUARD_NODE}" instead. addAtbashSafety() rewrites routes added through addEdge()/addConditionalEdges(); this one it cannot rewrite, and running it unjudged is not an option.`
312
+ );
313
+ };
314
+ for (const [from, to] of graph.edges) {
315
+ if (to === toolsNode && from !== GUARD_NODE) refuse(from, "an edge");
316
+ }
317
+ for (const [source, branches] of Object.entries(graph.branches)) {
318
+ if (source === GUARD_NODE) continue;
319
+ for (const branch of Object.values(branches)) {
320
+ for (const destination of Object.values(branch.ends ?? {})) {
321
+ if (destination === toolsNode) refuse(source, "a conditional edge");
322
+ }
323
+ if (!isGuardedBranch(branch)) refuse(source, "an unvetted conditional edge");
324
+ }
325
+ }
326
+ for (const [name, node] of Object.entries(graph.nodes)) {
327
+ if (name === GUARD_NODE) continue;
328
+ if (node?.ends?.includes(toolsNode)) refuse(name, "a Command route");
329
+ }
330
+ }
331
+ function sealRoutesIntoGuard(graph, toolsNode) {
332
+ if (graph[SEALED] === true) return;
333
+ Object.defineProperty(graph, SEALED, { value: true });
334
+ const addEdge = graph.addEdge.bind(graph);
335
+ const addConditionalEdges = graph.addConditionalEdges.bind(graph);
336
+ const compile = graph.compile.bind(graph);
337
+ graph.addEdge = (...args) => {
338
+ const result = addEdge(...args);
339
+ routeToolsThroughGuard(graph, toolsNode);
340
+ return result;
341
+ };
342
+ graph.addConditionalEdges = (...args) => {
343
+ const result = addConditionalEdges(...args);
344
+ routeToolsThroughGuard(graph, toolsNode);
345
+ return result;
346
+ };
347
+ graph.compile = (...args) => {
348
+ assertGuardOwnsToolsNode(graph, toolsNode);
349
+ return compile(...args);
350
+ };
351
+ }
191
352
  function addAtbashSafety(builder, opts) {
192
353
  setupTelemetry({ enabled: true, source: "plugin:langgraph" });
193
354
  process.once("beforeExit", () => shutdownTelemetry());
@@ -216,13 +377,23 @@ function addAtbashSafety(builder, opts) {
216
377
  const graph = builder;
217
378
  const toolsNode = opts.toolsNode ?? "tools";
218
379
  const agentNode = opts.agentNode ?? "agent";
219
- graph.addNode("atbash_guard", createGuardNode({ client, guardManager: guard }));
220
- graph.addNode("atbash_audit", createAuditNode({ client }));
221
- graph.addConditionalEdges("atbash_guard", (state) => {
222
- return state.atbashVerdict === "ALLOW" ? toolsNode : agentNode;
223
- });
224
- graph.addEdge(toolsNode, "atbash_audit");
225
- graph.addEdge("atbash_audit", agentNode);
380
+ routeToolsThroughGuard(graph, toolsNode);
381
+ const agentHasRoute = [...graph.edges].some(([from]) => from === agentNode) || graph.branches[agentNode] != null;
382
+ if (!agentHasRoute) graph.addEdge(agentNode, GUARD_NODE);
383
+ graph.addNode(GUARD_NODE, createGuardNode({ client, guardManager: guard }));
384
+ graph.addNode(AUDIT_NODE, createAuditNode({ client }));
385
+ graph.addConditionalEdges(
386
+ GUARD_NODE,
387
+ (state) => {
388
+ if (state.atbashVerdict === "ALLOW") return toolsNode;
389
+ if (state.atbashVerdict === "SKIP") return END;
390
+ return agentNode;
391
+ },
392
+ [toolsNode, agentNode, END]
393
+ );
394
+ graph.addEdge(toolsNode, AUDIT_NODE);
395
+ graph.addEdge(AUDIT_NODE, agentNode);
396
+ sealRoutesIntoGuard(graph, toolsNode);
226
397
  return builder;
227
398
  }
228
399
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atbash/atbash-langgraph",
3
- "version": "0.0.13",
3
+ "version": "0.0.15-dev.0",
4
4
  "description": "Atbash safety guard and audit nodes for LangGraph workflows",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -13,7 +13,8 @@
13
13
  },
14
14
  "files": [
15
15
  "dist",
16
- "README.md"
16
+ "README.md",
17
+ "LICENSE"
17
18
  ],
18
19
  "keywords": [
19
20
  "atbash",
@@ -25,7 +26,11 @@
25
26
  "audit",
26
27
  "policy"
27
28
  ],
28
- "license": "MIT",
29
+ "license": "SEE LICENSE IN LICENSE",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/Atbash-Ai/atbash-langgraph-plugin"
33
+ },
29
34
  "publishConfig": {
30
35
  "access": "public"
31
36
  },
@@ -33,11 +38,14 @@
33
38
  "node": ">=18.0.0"
34
39
  },
35
40
  "scripts": {
36
- "build": "tsup src/index.ts --format esm --dts --clean",
37
- "prepublishOnly": "npm run build"
41
+ "test": "tsc --noEmit && node test/build.mjs && vitest run && node --test test/guard-node.test.mjs test/graph.test.mjs",
42
+ "build": "node scripts/clean.mjs && tsup src/index.ts --format esm --dts --clean",
43
+ "verify:package": "node scripts/release.mjs --verify-current",
44
+ "release": "node scripts/release.mjs --channel latest",
45
+ "release:dev": "node scripts/release.mjs --channel dev"
38
46
  },
39
47
  "dependencies": {
40
- "@atbash/sdk": "^0.7.0",
48
+ "@atbash/sdk": "0.10.11-dev.0",
41
49
  "zod": "^3.25.76"
42
50
  },
43
51
  "peerDependencies": {
@@ -48,7 +56,9 @@
48
56
  "@langchain/core": "^1.1.45",
49
57
  "@langchain/langgraph": "^1.3.0",
50
58
  "@types/node": "^25.7.0",
59
+ "semver": "7.8.5",
51
60
  "tsup": "^8.0.0",
52
- "typescript": "^5.0.0"
61
+ "typescript": "^5.0.0",
62
+ "vitest": "^4.1.6"
53
63
  }
54
64
  }