@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 CHANGED
@@ -1,3 +1,48 @@
1
- # Temporary Holding Version
1
+ # @interlockd/sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }