@interlockd/sdk 0.0.0-stage → 0.1.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/README.md +47 -2
- package/package.json +12 -4
- package/src/index.d.ts +38 -0
- package/src/index.js +159 -0
package/README.md
CHANGED
|
@@ -1,3 +1,48 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @interlockd/sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Interlock for JavaScript and TypeScript agents: every tool call is checked before it runs, and every tool result
|
|
4
|
+
before the model reads it, by the local Interlock daemon, with the same policy and audit log as the agent hooks.
|
|
5
|
+
No dependencies; Node 18+.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pipx install interlockd && interlock init # the daemon (once per machine)
|
|
9
|
+
npm install @interlockd/sdk
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Vercel AI SDK, Mastra, or any `{ name: { execute } }` tool map
|
|
13
|
+
|
|
14
|
+
```js
|
|
15
|
+
import { generateText, tool } from "ai";
|
|
16
|
+
import { guardTools } from "@interlockd/sdk";
|
|
17
|
+
|
|
18
|
+
const tools = guardTools({ shell: tool({ description: "Run a command", inputSchema, execute: runShell }) });
|
|
19
|
+
await generateText({ model, tools, prompt });
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
A refused call never runs: the model receives Interlock's notice as the tool result. A result carrying injected
|
|
23
|
+
instructions is replaced before the model reads it. Verified with `ai` 7.0 (a real `generateText` loop).
|
|
24
|
+
|
|
25
|
+
## Claude Agent SDK (TypeScript)
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
29
|
+
import { guardOptions } from "@interlockd/sdk";
|
|
30
|
+
|
|
31
|
+
for await (const m of query({ prompt, options: guardOptions({ allowedTools: ["Bash", "Read"] }) })) { ... }
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`guardOptions` adds PreToolUse / PostToolUse hooks and keeps yours. Verified live with
|
|
35
|
+
`@anthropic-ai/claude-agent-sdk` 0.3 (a refused force-push, a withheld injected README).
|
|
36
|
+
|
|
37
|
+
## Anything else
|
|
38
|
+
|
|
39
|
+
```js
|
|
40
|
+
import { InterlockClient, guardExecute } from "@interlockd/sdk";
|
|
41
|
+
const safeRun = guardExecute("shell", runShell); // same signature, guarded
|
|
42
|
+
const v = await new InterlockClient().decide("pre_action", "shell", { command: "rm -rf build" });
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The client reads the token and daemon address from `~/.interlock` (or `INTERLOCK_HOME`, `INTERLOCK_URL`). With the
|
|
46
|
+
daemon unreachable, calls run unless the fail mode is closed (`INTERLOCK_FAIL_MODE=closed`).
|
|
47
|
+
|
|
48
|
+
License: Apache-2.0.
|
package/package.json
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@interlockd/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
}
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Interlock for JavaScript and TypeScript agents: check every tool call before it runs and every result before the model reads it.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": { ".": { "types": "./src/index.d.ts", "default": "./src/index.js" } },
|
|
7
|
+
"types": "./src/index.d.ts",
|
|
8
|
+
"files": ["src", "README.md"],
|
|
9
|
+
"engines": { "node": ">=18" },
|
|
10
|
+
"license": "Apache-2.0",
|
|
11
|
+
"repository": { "type": "git", "url": "https://github.com/interlockd/interlock", "directory": "sdk/js" },
|
|
12
|
+
"keywords": ["ai", "agents", "security", "mcp", "prompt-injection", "claude-agent-sdk", "vercel-ai-sdk"],
|
|
13
|
+
"scripts": { "test": "node --test test/" }
|
|
14
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export declare const VERSION: string;
|
|
2
|
+
export declare const UNREACHABLE: string;
|
|
3
|
+
|
|
4
|
+
export interface Verdict {
|
|
5
|
+
disposition: "allow" | "ask" | "block" | "warn" | "withhold";
|
|
6
|
+
mode: "shadow" | "enforce";
|
|
7
|
+
reason?: string | null;
|
|
8
|
+
notice?: string | null;
|
|
9
|
+
findings?: Array<{ rule?: string; category: string; severity: string; subject?: string | null }>;
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ClientOptions {
|
|
14
|
+
home?: string; url?: string; token?: string; agentType?: string; session?: string; timeoutMs?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export declare class InterlockClient {
|
|
18
|
+
constructor(options?: ClientOptions);
|
|
19
|
+
readonly url: string;
|
|
20
|
+
readonly token: string;
|
|
21
|
+
failsClosed(): boolean;
|
|
22
|
+
decide(kind: "pre_action" | "post_action", tool: string, args?: Record<string, unknown>,
|
|
23
|
+
options?: { content?: string; session?: string; cwd?: string; withhold?: boolean }): Promise<Verdict | null>;
|
|
24
|
+
static stops(v: Verdict | null): boolean;
|
|
25
|
+
static withholds(v: Verdict | null): boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export declare function guardExecute<F extends (args: any, ...rest: any[]) => any>(
|
|
29
|
+
name: string, execute: F, client?: InterlockClient, options?: { session?: string }): F;
|
|
30
|
+
|
|
31
|
+
export declare function guardTools<T extends Record<string, any>>(
|
|
32
|
+
tools: T, client?: InterlockClient, options?: { session?: string }): T;
|
|
33
|
+
|
|
34
|
+
export type HookCallback = (input: any, toolUseID: string | undefined, options: { signal: AbortSignal }) => Promise<Record<string, unknown>>;
|
|
35
|
+
export declare function interlockHooks(client?: InterlockClient): {
|
|
36
|
+
PreToolUse: Array<{ hooks: HookCallback[] }>; PostToolUse: Array<{ hooks: HookCallback[] }>;
|
|
37
|
+
};
|
|
38
|
+
export declare function guardOptions<O extends { hooks?: Record<string, any> }>(options?: O, client?: InterlockClient): O;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Interlock for JavaScript and TypeScript agents. No dependencies; Node 18+ (global fetch).
|
|
2
|
+
//
|
|
3
|
+
// import { guardTools } from "@interlockd/sdk"; // Vercel AI SDK, Mastra, any { name: { execute } } map
|
|
4
|
+
// const tools = guardTools({ shell, readFile });
|
|
5
|
+
//
|
|
6
|
+
// import { guardOptions } from "@interlockd/sdk"; // Claude Agent SDK (TypeScript)
|
|
7
|
+
// for await (const m of query({ prompt, options: guardOptions({ allowedTools: ["Bash"] }) })) { ... }
|
|
8
|
+
//
|
|
9
|
+
// Every call is decided by the local Interlock daemon (`interlock init` or `interlock start`), with the same policy
|
|
10
|
+
// and audit log as the agent hooks. A refused call never runs: the model receives Interlock's notice as the tool
|
|
11
|
+
// result. A result carrying injected instructions is replaced before the model reads it. With the daemon
|
|
12
|
+
// unreachable, calls run unless the fail mode is closed (INTERLOCK_FAIL_MODE=closed or ~/.interlock/fail-mode.json).
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
|
|
18
|
+
export const VERSION = "0.1.2";
|
|
19
|
+
export const UNREACHABLE =
|
|
20
|
+
"[interlock] This action was NOT executed: the Interlock daemon is unreachable and this machine fails closed.";
|
|
21
|
+
|
|
22
|
+
function read(path) {
|
|
23
|
+
try { return readFileSync(path, "utf8"); } catch { return null; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class InterlockClient {
|
|
27
|
+
constructor({ home, url, token, agentType = "js-sdk", session, timeoutMs = 1500 } = {}) {
|
|
28
|
+
this.home = home || process.env.INTERLOCK_HOME || join(homedir(), ".interlock");
|
|
29
|
+
this._url = url || process.env.INTERLOCK_URL || null;
|
|
30
|
+
this._token = token ?? null;
|
|
31
|
+
this.agentType = agentType;
|
|
32
|
+
this.session = session || null;
|
|
33
|
+
this.timeoutMs = timeoutMs;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get url() {
|
|
37
|
+
if (this._url) return this._url.replace(/\/$/, "");
|
|
38
|
+
let listen = "127.0.0.1:7391";
|
|
39
|
+
try { listen = JSON.parse(read(join(this.home, "daemon.json")) || "{}").listen || listen; } catch {}
|
|
40
|
+
return `http://${listen}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get token() {
|
|
44
|
+
if (this._token === null) this._token = (read(join(this.home, "token")) || "").trim();
|
|
45
|
+
return this._token;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
failsClosed() {
|
|
49
|
+
const env = process.env.INTERLOCK_FAIL_MODE;
|
|
50
|
+
if (env === "open" || env === "closed") return env === "closed";
|
|
51
|
+
try { return JSON.parse(read(join(this.home, "fail-mode.json")) || "{}").default === "closed"; } catch { return false; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async _post(path, body, timeoutMs) {
|
|
55
|
+
if (!this.token) return null;
|
|
56
|
+
try {
|
|
57
|
+
const r = await fetch(this.url + path, {
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: { authorization: `Bearer ${this.token}`, "content-type": "application/json" },
|
|
60
|
+
body: JSON.stringify(body),
|
|
61
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
62
|
+
});
|
|
63
|
+
if (!r.ok) return null;
|
|
64
|
+
const text = await r.text();
|
|
65
|
+
return text.trim() ? JSON.parse(text) : {};
|
|
66
|
+
} catch {
|
|
67
|
+
return null; // unreachable daemon: the caller applies the fail mode
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The daemon's verdict, or null when it can't be reached. */
|
|
72
|
+
decide(kind, tool, args = {}, { content, session, cwd, withhold = true } = {}) {
|
|
73
|
+
const event = {
|
|
74
|
+
kind, adapter: { name: this.agentType, version: VERSION },
|
|
75
|
+
session_hint: session || this.session, identity: { agent_type: this.agentType },
|
|
76
|
+
tool_call: { name: String(tool), arguments: args && typeof args === "object" && !Array.isArray(args) ? args : { value: args } },
|
|
77
|
+
context: { cwd: cwd || process.cwd() }, capabilities: { ask: false, rewrite: false, withhold },
|
|
78
|
+
};
|
|
79
|
+
if (content !== undefined) event.result = { content: String(content).slice(0, 200_000) };
|
|
80
|
+
return this._post("/interlock/v1/decide", event, kind === "pre_action" ? this.timeoutMs : this.timeoutMs + 1000);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
static stops(v) { return !!v && v.mode === "enforce" && (v.disposition === "block" || v.disposition === "ask"); }
|
|
84
|
+
static withholds(v) { return !!v && v.mode === "enforce" && v.disposition === "withhold"; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function asText(value) {
|
|
88
|
+
if (typeof value === "string") return value;
|
|
89
|
+
try { return JSON.stringify(value); } catch { return String(value); }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Wrap one tool's execute function. `execute(args, ...rest)` keeps its signature; a refused call returns Interlock's
|
|
94
|
+
* notice instead of running, and an injected result is replaced by the notice.
|
|
95
|
+
*/
|
|
96
|
+
export function guardExecute(name, execute, client = new InterlockClient(), { session } = {}) {
|
|
97
|
+
const wrapped = async function (args, ...rest) {
|
|
98
|
+
const v = await client.decide("pre_action", name, args, { session });
|
|
99
|
+
if (v === null && client.failsClosed()) return UNREACHABLE;
|
|
100
|
+
if (InterlockClient.stops(v)) return v.notice || `[interlock] refused: ${v.reason}`;
|
|
101
|
+
const result = await execute.call(this, args, ...rest);
|
|
102
|
+
const text = asText(result);
|
|
103
|
+
if (text && text.trim()) {
|
|
104
|
+
const p = await client.decide("post_action", name, args, { content: text, session });
|
|
105
|
+
if (InterlockClient.withholds(p)) return p.notice || "[interlock] tool result withheld";
|
|
106
|
+
}
|
|
107
|
+
return result;
|
|
108
|
+
};
|
|
109
|
+
wrapped.__interlock = true;
|
|
110
|
+
return wrapped;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Guard a tool map ({ name: { execute, ... } }): the Vercel AI SDK `tools` object, Mastra tools, and similar.
|
|
115
|
+
* Returns a new map; tools without `execute` (client-side tools) are passed through. Idempotent.
|
|
116
|
+
*/
|
|
117
|
+
export function guardTools(tools, client = new InterlockClient({ agentType: "js-sdk" }), opts = {}) {
|
|
118
|
+
const out = {};
|
|
119
|
+
for (const [name, t] of Object.entries(tools || {})) {
|
|
120
|
+
if (!t || typeof t.execute !== "function" || t.execute.__interlock) { out[name] = t; continue; }
|
|
121
|
+
out[name] = { ...t, execute: guardExecute(t.id || name, t.execute, client, opts) };
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---- Claude Agent SDK (TypeScript) ------------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
const SDK_ROUTE = "/interlock/v1/hooks/claude-agent-sdk?format=claude-code";
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* PreToolUse / PostToolUse hook callbacks for the Claude Agent SDK. Its hooks receive the same payloads as Claude
|
|
132
|
+
* Code's command hooks, so the daemon replies with the hook output Claude Code applies (deny with a reason, or a
|
|
133
|
+
* replaced tool result).
|
|
134
|
+
*/
|
|
135
|
+
export function interlockHooks(client = new InterlockClient({ agentType: "claude-agent-sdk" })) {
|
|
136
|
+
const call = (event) => async (input) => {
|
|
137
|
+
const body = { adapter: { name: "claude-agent-sdk", version: VERSION, event }, payload: input };
|
|
138
|
+
const out = await client._post(SDK_ROUTE, body, event === "pre" ? client.timeoutMs : client.timeoutMs + 1000);
|
|
139
|
+
if (out === null && event === "pre" && client.failsClosed()) {
|
|
140
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: UNREACHABLE } };
|
|
141
|
+
}
|
|
142
|
+
return out || {};
|
|
143
|
+
};
|
|
144
|
+
const pre = call("pre"); pre.__interlock = true;
|
|
145
|
+
const post = call("post"); post.__interlock = true;
|
|
146
|
+
return { PreToolUse: [{ hooks: [pre] }], PostToolUse: [{ hooks: [post] }] };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Add Interlock's hooks to Claude Agent SDK options, keeping yours (Interlock runs first). Idempotent. */
|
|
150
|
+
export function guardOptions(options = {}, client) {
|
|
151
|
+
const ours = interlockHooks(client);
|
|
152
|
+
const hooks = { ...(options.hooks || {}) };
|
|
153
|
+
for (const [event, matchers] of Object.entries(ours)) {
|
|
154
|
+
const existing = hooks[event] || [];
|
|
155
|
+
const has = existing.some((m) => (m.hooks || []).some((h) => h.__interlock));
|
|
156
|
+
hooks[event] = has ? existing : [...matchers, ...existing];
|
|
157
|
+
}
|
|
158
|
+
return { ...options, hooks };
|
|
159
|
+
}
|