@interlok/cli 0.0.0-stage → 1.3.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 +5 -0
- package/README.md +158 -2
- package/dist/approvals-mcp.d.ts +83 -0
- package/dist/approvals-mcp.js +185 -0
- package/dist/canonical.d.ts +1 -0
- package/dist/canonical.js +20 -0
- package/dist/classify.d.ts +107 -0
- package/dist/classify.js +524 -0
- package/dist/gate.d.ts +19 -0
- package/dist/gate.js +338 -0
- package/dist/hook.d.ts +108 -0
- package/dist/hook.js +578 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +230 -0
- package/dist/inventory.d.ts +39 -0
- package/dist/inventory.js +177 -0
- package/dist/packs.d.ts +105 -0
- package/dist/packs.js +186 -0
- package/dist/proof-verify.d.ts +108 -0
- package/dist/proof-verify.js +513 -0
- package/dist/proxy.d.ts +69 -0
- package/dist/proxy.js +361 -0
- package/dist/scan.d.ts +7 -0
- package/dist/scan.js +41 -0
- package/dist/verify.d.ts +34 -0
- package/dist/verify.js +123 -0
- package/package.json +31 -4
package/dist/hook.js
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { homedir, hostname } from "node:os";
|
|
5
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
6
|
+
import { canonical } from "./canonical.js";
|
|
7
|
+
/**
|
|
8
|
+
* Seconds each agent's config gives the hook. Gemini CLI and GitHub Copilot let a call through when
|
|
9
|
+
* a hook times out, so the wait for a person is always capped well inside this.
|
|
10
|
+
*/
|
|
11
|
+
export const HOOK_TIMEOUT_S = 120;
|
|
12
|
+
export const MAX_WAIT_S = HOOK_TIMEOUT_S - 20;
|
|
13
|
+
const SHELL = "^Bash$";
|
|
14
|
+
/** Built-in rules: the actions that leave the worktree and are hard to take back. */
|
|
15
|
+
export const DEFAULT_RULES = [
|
|
16
|
+
{
|
|
17
|
+
id: "git.push_protected",
|
|
18
|
+
label: "Push to a protected branch, or a force push",
|
|
19
|
+
match: String.raw `\bgit\s+push\b(?=[^;&|]*(?:[\s:+](?:main|master|production|release)(?:\s|$|[;&|])|\s(?:-f|--force(?:-with-lease)?|--mirror|--delete|-d)\b))`,
|
|
20
|
+
},
|
|
21
|
+
{ id: "github.pr_merge", label: "Merge a pull request", match: String.raw `\bgh\s+pr\s+merge\b` },
|
|
22
|
+
{
|
|
23
|
+
id: "deploy.production",
|
|
24
|
+
label: "Production deploy",
|
|
25
|
+
match: String.raw `\b(?:vercel\b[^;&|]*\s--prod\b|vercel\s+promote\b|netlify\s+deploy\b[^;&|]*--prod\b|fly(?:ctl)?\s+deploy\b|railway\s+up\b|gcloud\s+(?:run|app)\s+deploy\b|aws\s+cloudformation\s+deploy\b|heroku\s+(?:releases:rollback|ps:scale)\b)`,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "infra.apply",
|
|
29
|
+
label: "Infrastructure change",
|
|
30
|
+
match: String.raw `\b(?:terraform\s+(?:apply|destroy|import|state\s+rm)\b|tofu\s+(?:apply|destroy)\b|pulumi\s+(?:up|destroy)\b|kubectl\s+(?:apply|delete|replace|patch|scale|rollout\s+restart|drain)\b|helm\s+(?:install|upgrade|uninstall|rollback)\b)`,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: "db.migrate",
|
|
34
|
+
label: "Database migration or destructive SQL",
|
|
35
|
+
match: String.raw `\b(?:supabase\s+db\s+(?:push|reset)\b|prisma\s+(?:migrate\s+(?:deploy|reset)|db\s+push)\b|drizzle-kit\s+(?:push|migrate)\b|rails\s+db:(?:migrate|drop|reset)\b|alembic\s+upgrade\b|(?:drop|truncate)\s+(?:table|database|schema)\b|delete\s+from\s+\w+\s*(?:;|$))`,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "package.publish",
|
|
39
|
+
label: "Publish a package or image",
|
|
40
|
+
match: String.raw `\b(?:(?:npm|pnpm|bun)\s+publish\b|yarn\s+npm\s+publish\b|cargo\s+publish\b|twine\s+upload\b|gem\s+push\b|docker\s+push\b|gh\s+release\s+create\b)`,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: "secrets.change",
|
|
44
|
+
label: "Change secrets or environment variables",
|
|
45
|
+
match: String.raw `\b(?:gh\s+secret\s+(?:set|delete)\b|vercel\s+env\s+(?:add|rm|remove)\b|aws\s+secretsmanager\s+(?:put|delete|update)|doppler\s+secrets\s+(?:set|delete)\b|fly\s+secrets\s+(?:set|unset)\b)`,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "fs.destructive",
|
|
49
|
+
label: "Delete outside the project",
|
|
50
|
+
match: String.raw `\brm\s+-[a-zA-Z]*[rR][a-zA-Z]*\s+(?:-[a-zA-Z]+\s+)*(?:/|~|\$HOME)(?:\s|/?\*?$|;)`,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: "mcp.write",
|
|
54
|
+
label: "An MCP tool that changes something",
|
|
55
|
+
// Orchestration inside the developer's own machine (Paradev workspaces, terminals, tasks) and
|
|
56
|
+
// file edits through a filesystem server stay ungated; standing automations do not.
|
|
57
|
+
tool: String.raw `^(?!mcp__(?:superset|paradev)__(?:workspaces|agents|terminals|tasks|pages|hosts|projects)_)(?!mcp__filesystem__)mcp__(?!interlok)[^_].*__(?:[a-z0-9_]*_)?(?:create|update|delete|remove|send|merge|deploy|publish|refund|charge|transfer|drop|write|push|post|cancel|revoke|grant)(?:_[a-z0-9_]*)?$`,
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
/** Read the agent's stdin payload into one shape. Returns null for anything the hook ignores. */
|
|
61
|
+
export function parseCall(agent, raw) {
|
|
62
|
+
if (!raw || typeof raw !== "object")
|
|
63
|
+
return null;
|
|
64
|
+
const cwd = String(raw.cwd ?? raw.workspace_roots?.[0] ?? process.cwd());
|
|
65
|
+
if (agent === "cursor") {
|
|
66
|
+
const session = String(raw.conversation_id ?? raw.generation_id ?? "cursor");
|
|
67
|
+
if (raw.hook_event_name === "beforeMCPExecution") {
|
|
68
|
+
let input = raw.tool_input;
|
|
69
|
+
if (typeof input === "string") {
|
|
70
|
+
try {
|
|
71
|
+
input = JSON.parse(input);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
/* keep the string */
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const server = String(raw.mcp_server_name ?? "server").replace(/[^A-Za-z0-9_-]/g, "_");
|
|
78
|
+
return { agent, tool: `mcp__${server}__${raw.tool_name ?? "tool"}`, input, session, cwd };
|
|
79
|
+
}
|
|
80
|
+
// beforeShellExecution (stdio MCP events also carry `command`, handled above).
|
|
81
|
+
if (typeof raw.command === "string") {
|
|
82
|
+
const command = raw.command;
|
|
83
|
+
return { agent, tool: "Bash", input: command, command, session, cwd };
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
if (agent === "gemini") {
|
|
88
|
+
const session = String(raw.session_id ?? "gemini");
|
|
89
|
+
const name = String(raw.tool_name ?? "");
|
|
90
|
+
const ti = raw.tool_input ?? {};
|
|
91
|
+
if (name === "run_shell_command" && typeof ti.command === "string") {
|
|
92
|
+
const command = ti.command;
|
|
93
|
+
return { agent, tool: "Bash", input: command, command, session, cwd };
|
|
94
|
+
}
|
|
95
|
+
if (name.startsWith("mcp_")) {
|
|
96
|
+
// Gemini names MCP tools mcp_<server>_<tool>; the context says where the split is.
|
|
97
|
+
const ctx = raw.mcp_context ?? {};
|
|
98
|
+
const server = String(ctx.server_name ?? "gemini").replace(/[^A-Za-z0-9_-]/g, "_");
|
|
99
|
+
const t = String(ctx.tool_name ?? name.slice(4));
|
|
100
|
+
return { agent, tool: `mcp__${server}__${t}`, input: ti, session, cwd };
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
// Claude Code, Codex and GitHub Copilot (whose PascalCase PreToolUse payload uses Claude's
|
|
105
|
+
// field names and reports shell commands as "Bash").
|
|
106
|
+
const tool = String(raw.tool_name ?? "");
|
|
107
|
+
const session = String(raw.session_id ?? raw.turn_id ?? "session");
|
|
108
|
+
let ti = raw.tool_input ?? {};
|
|
109
|
+
if (typeof ti === "string") {
|
|
110
|
+
try {
|
|
111
|
+
ti = JSON.parse(ti);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
ti = { command: ti };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (/^(?:Bash|shell|exec_command|local_shell|powershell)$/i.test(tool)) {
|
|
118
|
+
const cmd = Array.isArray(ti.command)
|
|
119
|
+
? ti.command.join(" ")
|
|
120
|
+
: String(ti.command ?? ti.cmd ?? "");
|
|
121
|
+
// Codex may wrap the line as ["bash", "-lc", "<line>"].
|
|
122
|
+
const command = cmd.replace(/^(?:ba|z)?sh\s+-l?c\s+/, "");
|
|
123
|
+
return { agent, tool: "Bash", input: command, command, session, cwd };
|
|
124
|
+
}
|
|
125
|
+
if (tool.startsWith("mcp__"))
|
|
126
|
+
return { agent, tool, input: ti, session, cwd };
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
const compiled = new Map();
|
|
130
|
+
const re = (s) => {
|
|
131
|
+
let r = compiled.get(s);
|
|
132
|
+
if (!r)
|
|
133
|
+
compiled.set(s, (r = new RegExp(s, "i")));
|
|
134
|
+
return r;
|
|
135
|
+
};
|
|
136
|
+
/** The first rule a call matches, or null. Pure: no network, no file access. */
|
|
137
|
+
export function matchRule(policy, call) {
|
|
138
|
+
const rules = [...(policy.rules ?? []), ...(policy.defaults === false ? [] : DEFAULT_RULES)];
|
|
139
|
+
const text = call.command ?? (typeof call.input === "string" ? call.input : canonical(call.input ?? null));
|
|
140
|
+
for (const r of rules) {
|
|
141
|
+
if (!re(r.tool ?? SHELL).test(call.tool))
|
|
142
|
+
continue;
|
|
143
|
+
if (r.match && !re(r.match).test(text))
|
|
144
|
+
continue;
|
|
145
|
+
if (r.unless && re(r.unless).test(text))
|
|
146
|
+
continue;
|
|
147
|
+
return r;
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
/** Strip credentials from a command line before an approver (and Interlok) sees it. */
|
|
152
|
+
export function scrub(s) {
|
|
153
|
+
return s
|
|
154
|
+
.replace(/(\w+:\/\/)[^\s/@:]+:[^\s/@]+@/g, "$1***:***@")
|
|
155
|
+
.replace(/\b(sk_(?:live|test)_|rk_(?:live|test)_|ghp_|gho_|ghs_|github_pat_|xox[abpr]-|ilk_|sk-|AKIA|ASIA|glpat-|npm_|sset_wh_|sk_live_)[A-Za-z0-9_\-./+=]{6,}/g, "$1***")
|
|
156
|
+
.replace(/(authorization:\s*(?:bearer|basic)\s+)\S+/gi, "$1***")
|
|
157
|
+
.replace(/\b([A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|PASS|CREDENTIALS?)[A-Z0-9_]*=)("[^"]*"|'[^']*'|\S+)/g, "$1***")
|
|
158
|
+
.replace(/(--?(?:password|passwd|token|secret|api-key|apikey|key)[= ])("[^"]*"|'[^']*'|\S+)/gi, "$1***");
|
|
159
|
+
}
|
|
160
|
+
export const payloadHash = (call) => createHash("sha256")
|
|
161
|
+
.update(canonical({ tool: call.tool, input: call.input ?? null }))
|
|
162
|
+
.digest("hex");
|
|
163
|
+
/** What the agent receives on stdout. `null` means print nothing (allow). */
|
|
164
|
+
export function render(agent, v) {
|
|
165
|
+
if (agent === "cursor") {
|
|
166
|
+
// Cursor treats a missing or invalid answer from a permission hook as a block: always answer.
|
|
167
|
+
return JSON.stringify(v.allow
|
|
168
|
+
? { permission: "allow" }
|
|
169
|
+
: { permission: "deny", user_message: v.reason, agent_message: v.reason });
|
|
170
|
+
}
|
|
171
|
+
if (agent === "gemini")
|
|
172
|
+
return v.allow ? null : JSON.stringify({ decision: "deny", reason: v.reason });
|
|
173
|
+
if (agent === "copilot")
|
|
174
|
+
// "{}" is "no decision": the normal permission flow continues. A deny also exits 2 (see exitCode).
|
|
175
|
+
return JSON.stringify(v.allow ? {} : { permissionDecision: "deny", permissionDecisionReason: v.reason });
|
|
176
|
+
if (v.allow)
|
|
177
|
+
return null; // Claude Code: normal permission flow. Codex: allow is the empty answer.
|
|
178
|
+
return JSON.stringify({
|
|
179
|
+
hookSpecificOutput: {
|
|
180
|
+
hookEventName: "PreToolUse",
|
|
181
|
+
permissionDecision: "deny",
|
|
182
|
+
permissionDecisionReason: v.reason,
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Process exit code for a verdict. Copilot denies a tool call on any non-zero exit, whatever stdout
|
|
188
|
+
* says, so a refusal exits 2 there as well; every other agent reads the JSON and exits 0.
|
|
189
|
+
*/
|
|
190
|
+
export function exitCode(agent, v) {
|
|
191
|
+
return agent === "copilot" && !v.allow ? 2 : 0;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* GitHub Copilot also runs the hooks in a repository's .claude/settings.json, with its own payload
|
|
195
|
+
* (no transcript_path, an ISO timestamp). When the repository has Interlok's Copilot hook too, the
|
|
196
|
+
* Claude entry steps aside so one tool call opens one request, not two.
|
|
197
|
+
*/
|
|
198
|
+
export function deferToCopilotHook(agent, raw, copilotHookInRepo) {
|
|
199
|
+
return (agent === "claude" &&
|
|
200
|
+
copilotHookInRepo &&
|
|
201
|
+
!!raw &&
|
|
202
|
+
raw.transcript_path === undefined &&
|
|
203
|
+
typeof raw.timestamp === "string");
|
|
204
|
+
}
|
|
205
|
+
const STATE_TTL_MS = 7 * 24 * 3600_000;
|
|
206
|
+
async function readState(file) {
|
|
207
|
+
try {
|
|
208
|
+
const s = JSON.parse(await readFile(file, "utf8"));
|
|
209
|
+
const cutoff = Date.now() - STATE_TTL_MS;
|
|
210
|
+
for (const k of Object.keys(s))
|
|
211
|
+
if (!(s[k].at > cutoff))
|
|
212
|
+
delete s[k];
|
|
213
|
+
return s;
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return {};
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async function writeState(file, s) {
|
|
220
|
+
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
|
221
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
222
|
+
await writeFile(tmp, JSON.stringify(s), { mode: 0o600 });
|
|
223
|
+
await rename(tmp, file);
|
|
224
|
+
}
|
|
225
|
+
function heldReason(a, rule) {
|
|
226
|
+
const tag = a.hold?.tag_number ? ` (tag ${a.hold.tag_number})` : "";
|
|
227
|
+
return (`Interlok is holding this for a person${tag}: ${rule.label ?? rule.id}.` +
|
|
228
|
+
(a.console_url ? ` Approvers decide at ${a.console_url}.` : "") +
|
|
229
|
+
" Do not run it another way or work around the hold. Continue with other work and retry this exact command later; it runs once a person approves.");
|
|
230
|
+
}
|
|
231
|
+
function closedReason(a) {
|
|
232
|
+
const tag = a.hold?.tag_number ? ` (tag ${a.hold.tag_number})` : "";
|
|
233
|
+
if (a.status === "denied") {
|
|
234
|
+
const who = (a.determinations ?? []).map((d) => d.by).join(", ") || "a person";
|
|
235
|
+
const note = (a.determinations ?? []).find((d) => d.note)?.note;
|
|
236
|
+
return `Denied by ${who}${tag}. Do not run this command.${note ? ` Reason: ${note}` : ""}`;
|
|
237
|
+
}
|
|
238
|
+
return `No approval${tag}: the request ${a.status}. Do not run this command. Ask again only if the user wants it.`;
|
|
239
|
+
}
|
|
240
|
+
/** Decide one call. Every path that is not a person's approval returns a refusal. */
|
|
241
|
+
export async function decide(call, policy, deps) {
|
|
242
|
+
const rule = matchRule(policy, call);
|
|
243
|
+
if (!rule)
|
|
244
|
+
return { allow: true };
|
|
245
|
+
if (!deps.key)
|
|
246
|
+
return {
|
|
247
|
+
allow: false,
|
|
248
|
+
reason: `Interlok policy "${rule.id}" needs a person's approval, and INTERLOK_API_KEY is not set, so this command is blocked. Set the key (approvals:write scope) and retry.`,
|
|
249
|
+
};
|
|
250
|
+
const f = deps.fetch ?? fetch;
|
|
251
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
252
|
+
const now = deps.now ?? Date.now;
|
|
253
|
+
const call_ = async (path, init = {}) => {
|
|
254
|
+
const r = await f(`${deps.api}${path}`, {
|
|
255
|
+
...init,
|
|
256
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${deps.key}` },
|
|
257
|
+
signal: AbortSignal.timeout(20_000),
|
|
258
|
+
});
|
|
259
|
+
const body = await r.json().catch(() => ({}));
|
|
260
|
+
if (!r.ok)
|
|
261
|
+
throw Object.assign(new Error(body?.error ?? `HTTP ${r.status}`), { status: r.status });
|
|
262
|
+
return body;
|
|
263
|
+
};
|
|
264
|
+
const hash = payloadHash(call);
|
|
265
|
+
const sessionKey = createHash("sha256").update(call.session).digest("hex").slice(0, 32);
|
|
266
|
+
const file = join(deps.stateDir, `${sessionKey}.json`);
|
|
267
|
+
const slot = `${rule.id}:${hash}`;
|
|
268
|
+
try {
|
|
269
|
+
const state = await readState(file);
|
|
270
|
+
let entry = state[slot];
|
|
271
|
+
let a;
|
|
272
|
+
if (entry?.id) {
|
|
273
|
+
a = await call_(`/api/v1/approvals/${encodeURIComponent(entry.id)}`);
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
entry = entry ?? { idem: randomUUID(), at: Date.now() };
|
|
277
|
+
state[slot] = entry;
|
|
278
|
+
await writeState(file, state); // before the POST, so a crash retries the same request
|
|
279
|
+
const hashOnly = policy.summary === "hash-only";
|
|
280
|
+
const what = call.command !== undefined
|
|
281
|
+
? hashOnly
|
|
282
|
+
? `command sha256:${hash.slice(0, 16)}…`
|
|
283
|
+
: scrub(call.command).slice(0, 380)
|
|
284
|
+
: `${call.tool}${hashOnly ? "" : ` ${scrub(canonical(call.input ?? null)).slice(0, 300)}`}`;
|
|
285
|
+
a = await call_("/api/v1/approvals", {
|
|
286
|
+
method: "POST",
|
|
287
|
+
body: JSON.stringify({
|
|
288
|
+
action: `hook.${rule.id}`.slice(0, 120),
|
|
289
|
+
summary: `${rule.label ?? rule.id}: ${what}`.slice(0, 500),
|
|
290
|
+
payload_hash: hash,
|
|
291
|
+
agent: { name: deps.agentName },
|
|
292
|
+
environment: policy.environment ?? "production",
|
|
293
|
+
approvers_required: rule.approvers,
|
|
294
|
+
idempotency_key: entry.idem,
|
|
295
|
+
context: { agent: call.agent, rule: rule.id, ...(deps.context ?? {}) },
|
|
296
|
+
}),
|
|
297
|
+
});
|
|
298
|
+
entry.id = a.id;
|
|
299
|
+
await writeState(file, state);
|
|
300
|
+
}
|
|
301
|
+
const deadline = now() + deps.waitSeconds * 1000;
|
|
302
|
+
while (a.status === "held" && now() < deadline) {
|
|
303
|
+
await sleep(2000);
|
|
304
|
+
a = await call_(`/api/v1/approvals/${encodeURIComponent(a.id)}`);
|
|
305
|
+
}
|
|
306
|
+
if (a.status === "held")
|
|
307
|
+
return { allow: false, reason: heldReason(a, rule) };
|
|
308
|
+
// Approved, denied or expired: this approval is spent either way.
|
|
309
|
+
delete state[slot];
|
|
310
|
+
await writeState(file, state);
|
|
311
|
+
if (a.status === "approved")
|
|
312
|
+
return { allow: true };
|
|
313
|
+
return { allow: false, reason: closedReason(a) };
|
|
314
|
+
}
|
|
315
|
+
catch (e) {
|
|
316
|
+
const status = e.status;
|
|
317
|
+
if (status === undefined && deps.failOpen)
|
|
318
|
+
return { allow: true };
|
|
319
|
+
return {
|
|
320
|
+
allow: false,
|
|
321
|
+
reason: status === undefined
|
|
322
|
+
? `Interlok could not be reached (${e.message}), and "${rule.id}" needs a person's approval, so this command is blocked.`
|
|
323
|
+
: `Interlok refused the approval request (${status}: ${e.message}). "${rule.id}" needs a person's approval, so this command is blocked.`,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/* -------------------------------------- policy file -------------------------------------- */
|
|
328
|
+
/** Nearest .interlok/policy.json walking up from `dir` (stops at the filesystem root). */
|
|
329
|
+
export async function loadPolicy(dir) {
|
|
330
|
+
let d = resolve(dir);
|
|
331
|
+
for (;;) {
|
|
332
|
+
const f = join(d, ".interlok", "policy.json");
|
|
333
|
+
if (existsSync(f)) {
|
|
334
|
+
const p = JSON.parse(await readFile(f, "utf8"));
|
|
335
|
+
for (const r of p.rules ?? []) {
|
|
336
|
+
if (!r.id)
|
|
337
|
+
throw new Error(`${f}: every rule needs an id`);
|
|
338
|
+
for (const k of ["tool", "match", "unless"])
|
|
339
|
+
if (r[k])
|
|
340
|
+
new RegExp(r[k]);
|
|
341
|
+
}
|
|
342
|
+
return { policy: p, file: f };
|
|
343
|
+
}
|
|
344
|
+
const up = dirname(d);
|
|
345
|
+
if (up === d)
|
|
346
|
+
return { policy: {}, file: null };
|
|
347
|
+
d = up;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
export const DEFAULT_POLICY_FILE = {
|
|
351
|
+
version: 1,
|
|
352
|
+
defaults: true,
|
|
353
|
+
environment: "production",
|
|
354
|
+
wait: 90,
|
|
355
|
+
summary: "command",
|
|
356
|
+
rules: [],
|
|
357
|
+
};
|
|
358
|
+
/* ---------------------------------------- init ---------------------------------------- */
|
|
359
|
+
const MARK = "interlok hook";
|
|
360
|
+
const hookCmd = (agent, bin) => `${bin} hook --agent ${agent}`;
|
|
361
|
+
export const COPILOT_HOOK_FILE = join(".github", "hooks", "interlok.json");
|
|
362
|
+
/** Merge Interlok's hook into each agent's project config. Idempotent; never removes entries. */
|
|
363
|
+
export async function init(dir, agents, bin) {
|
|
364
|
+
const wrote = [];
|
|
365
|
+
const json = async (f) => {
|
|
366
|
+
try {
|
|
367
|
+
return JSON.parse(await readFile(f, "utf8"));
|
|
368
|
+
}
|
|
369
|
+
catch (e) {
|
|
370
|
+
if (e.code === "ENOENT")
|
|
371
|
+
return {};
|
|
372
|
+
throw new Error(`${f} is not valid JSON; fix it and run init again`);
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
const save = async (f, v) => {
|
|
376
|
+
await mkdir(dirname(f), { recursive: true });
|
|
377
|
+
await writeFile(f, `${JSON.stringify(v, null, 2)}\n`);
|
|
378
|
+
wrote.push(f);
|
|
379
|
+
};
|
|
380
|
+
const has = (list) => JSON.stringify(list ?? []).includes(MARK) ||
|
|
381
|
+
JSON.stringify(list ?? []).includes("@interlok/cli hook");
|
|
382
|
+
const policyFile = join(dir, ".interlok", "policy.json");
|
|
383
|
+
if (!existsSync(policyFile))
|
|
384
|
+
await save(policyFile, DEFAULT_POLICY_FILE);
|
|
385
|
+
for (const agent of agents) {
|
|
386
|
+
if (agent === "claude" || agent === "codex") {
|
|
387
|
+
const f = join(dir, agent === "claude" ? ".claude/settings.json" : ".codex/hooks.json");
|
|
388
|
+
const c = await json(f);
|
|
389
|
+
c.hooks ??= {};
|
|
390
|
+
c.hooks.PreToolUse ??= [];
|
|
391
|
+
if (!has(c.hooks.PreToolUse)) {
|
|
392
|
+
c.hooks.PreToolUse.push({
|
|
393
|
+
matcher: agent === "claude" ? "Bash|mcp__.*" : "^Bash$|^mcp__",
|
|
394
|
+
hooks: [{ type: "command", command: hookCmd(agent, bin), timeout: HOOK_TIMEOUT_S }],
|
|
395
|
+
});
|
|
396
|
+
await save(f, c);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
else if (agent === "gemini") {
|
|
400
|
+
const f = join(dir, ".gemini/settings.json");
|
|
401
|
+
const c = await json(f);
|
|
402
|
+
c.hooks ??= {};
|
|
403
|
+
c.hooks.BeforeTool ??= [];
|
|
404
|
+
if (!has(c.hooks.BeforeTool)) {
|
|
405
|
+
c.hooks.BeforeTool.push({
|
|
406
|
+
matcher: "^(run_shell_command|mcp_.*)$",
|
|
407
|
+
hooks: [
|
|
408
|
+
{
|
|
409
|
+
name: "interlok",
|
|
410
|
+
type: "command",
|
|
411
|
+
command: hookCmd("gemini", bin),
|
|
412
|
+
timeout: HOOK_TIMEOUT_S * 1000, // milliseconds; a timed-out hook lets the call run
|
|
413
|
+
},
|
|
414
|
+
],
|
|
415
|
+
});
|
|
416
|
+
await save(f, c);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
else if (agent === "copilot") {
|
|
420
|
+
// Its own file: the Copilot cloud agent reads only .github/hooks/*.json.
|
|
421
|
+
const f = join(dir, COPILOT_HOOK_FILE);
|
|
422
|
+
const c = await json(f);
|
|
423
|
+
c.version ??= 1;
|
|
424
|
+
c.hooks ??= {};
|
|
425
|
+
c.hooks.PreToolUse ??= [];
|
|
426
|
+
if (!has(c.hooks.PreToolUse)) {
|
|
427
|
+
c.hooks.PreToolUse.push({
|
|
428
|
+
type: "command",
|
|
429
|
+
bash: hookCmd("copilot", bin),
|
|
430
|
+
powershell: hookCmd("copilot", bin),
|
|
431
|
+
timeoutSec: HOOK_TIMEOUT_S,
|
|
432
|
+
});
|
|
433
|
+
await save(f, c);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
const f = join(dir, ".cursor/hooks.json");
|
|
438
|
+
const c = await json(f);
|
|
439
|
+
c.version ??= 1;
|
|
440
|
+
c.hooks ??= {};
|
|
441
|
+
for (const ev of ["beforeShellExecution", "beforeMCPExecution"]) {
|
|
442
|
+
c.hooks[ev] ??= [];
|
|
443
|
+
if (!has(c.hooks[ev])) {
|
|
444
|
+
c.hooks[ev].push({
|
|
445
|
+
command: hookCmd("cursor", bin),
|
|
446
|
+
timeout: HOOK_TIMEOUT_S,
|
|
447
|
+
failClosed: true,
|
|
448
|
+
});
|
|
449
|
+
await save(f, c);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return wrote;
|
|
455
|
+
}
|
|
456
|
+
/* ----------------------------------------- CLI ----------------------------------------- */
|
|
457
|
+
async function readStdin() {
|
|
458
|
+
const chunks = [];
|
|
459
|
+
for await (const c of process.stdin)
|
|
460
|
+
chunks.push(c);
|
|
461
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
462
|
+
}
|
|
463
|
+
const AGENTS = ["claude", "codex", "cursor", "gemini", "copilot"];
|
|
464
|
+
export async function hookCommand(args, value, has) {
|
|
465
|
+
const sub = args[0] && !args[0].startsWith("-") ? args[0] : "run";
|
|
466
|
+
if (sub === "init") {
|
|
467
|
+
const want = value("--agent") ?? "all";
|
|
468
|
+
const agents = want === "all" ? AGENTS : want.split(",");
|
|
469
|
+
for (const a of agents)
|
|
470
|
+
if (!AGENTS.includes(a))
|
|
471
|
+
throw new Error(`unknown agent ${a}`);
|
|
472
|
+
const bin = has("--npx") ? "npx -y @interlok/cli" : "interlok";
|
|
473
|
+
const wrote = await init(resolve(value("--dir") ?? "."), agents, bin);
|
|
474
|
+
console.log(wrote.length ? `Wrote:\n ${wrote.join("\n ")}` : "Already set up; nothing to change.");
|
|
475
|
+
console.log(`\nNext: set INTERLOK_API_KEY (approvals:write) in the agent's environment${bin === "interlok" ? ", and install the CLI globally (npm i -g @interlok/cli) so each check starts fast" : ""}.\nTry a rule without running anything: interlok hook test "git push origin main"`);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (sub === "test") {
|
|
479
|
+
const command = args
|
|
480
|
+
.slice(1)
|
|
481
|
+
.filter((a) => !a.startsWith("--"))
|
|
482
|
+
.join(" ");
|
|
483
|
+
const { policy, file } = await loadPolicy(value("--dir") ?? process.cwd());
|
|
484
|
+
const rule = matchRule(policy, {
|
|
485
|
+
agent: "claude",
|
|
486
|
+
tool: "Bash",
|
|
487
|
+
input: command,
|
|
488
|
+
command,
|
|
489
|
+
session: "test",
|
|
490
|
+
cwd: ".",
|
|
491
|
+
});
|
|
492
|
+
console.log(rule
|
|
493
|
+
? `HELD by ${rule.id} (${rule.label ?? ""}). A person must approve it.`
|
|
494
|
+
: "ALLOWED. No rule matches; nothing leaves this machine.");
|
|
495
|
+
console.log(`policy: ${file ?? "built-in defaults"}`);
|
|
496
|
+
console.log(`shown to the approver: ${scrub(command)}`);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
// sub === "run": called by the agent with the hook payload on stdin.
|
|
500
|
+
const agent = (value("--agent") ?? "claude");
|
|
501
|
+
if (!AGENTS.includes(agent))
|
|
502
|
+
throw new Error(`--agent must be one of ${AGENTS.join(", ")}`);
|
|
503
|
+
const text = await readStdin();
|
|
504
|
+
let raw = null;
|
|
505
|
+
try {
|
|
506
|
+
raw = JSON.parse(text || "null");
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
// Something we cannot read could be anything: refuse rather than guess.
|
|
510
|
+
return answer(agent, {
|
|
511
|
+
allow: false,
|
|
512
|
+
reason: "Interlok hook could not read the tool call; blocked.",
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
const call = parseCall(agent, raw);
|
|
516
|
+
if (!call)
|
|
517
|
+
return answer(agent, { allow: true });
|
|
518
|
+
if (deferToCopilotHook(agent, raw, await copilotHookIn(call.cwd)))
|
|
519
|
+
return answer(agent, { allow: true });
|
|
520
|
+
let verdict;
|
|
521
|
+
try {
|
|
522
|
+
const { policy } = await loadPolicy(call.cwd);
|
|
523
|
+
const ws = process.env.SUPERSET_WORKSPACE_NAME ?? process.env.PARADEV_WORKSPACE_NAME;
|
|
524
|
+
verdict = await decide(call, policy, {
|
|
525
|
+
api: (process.env.INTERLOK_URL ?? value("--api") ?? "https://interlok.io").replace(/\/$/, ""),
|
|
526
|
+
key: process.env.INTERLOK_API_KEY ?? "",
|
|
527
|
+
agentName: value("--agent-name") ?? process.env.INTERLOK_AGENT ?? `${agent}@${basename(call.cwd)}`,
|
|
528
|
+
// Never longer than the agent gives the hook: some agents run the call when a hook times out.
|
|
529
|
+
waitSeconds: Math.min(Number(value("--wait") ?? policy.wait ?? 90) || 0, MAX_WAIT_S),
|
|
530
|
+
failOpen: has("--fail-open"),
|
|
531
|
+
stateDir: process.env.INTERLOK_HOOK_STATE ??
|
|
532
|
+
join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "interlok", "hook"),
|
|
533
|
+
context: {
|
|
534
|
+
project: basename(call.cwd),
|
|
535
|
+
host: hostname().slice(0, 120),
|
|
536
|
+
...(ws ? { workspace: ws.slice(0, 200) } : {}),
|
|
537
|
+
},
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
catch (e) {
|
|
541
|
+
// A broken policy file must not open the gate.
|
|
542
|
+
verdict = {
|
|
543
|
+
allow: false,
|
|
544
|
+
reason: `Interlok hook error: ${e.message}. Blocked until fixed.`,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
answer(agent, verdict);
|
|
548
|
+
}
|
|
549
|
+
function answer(agent, v) {
|
|
550
|
+
const out = render(agent, v);
|
|
551
|
+
if (out)
|
|
552
|
+
process.stdout.write(out);
|
|
553
|
+
if (!v.allow)
|
|
554
|
+
process.stderr.write(`${v.reason}\n`);
|
|
555
|
+
process.exitCode = exitCode(agent, v);
|
|
556
|
+
}
|
|
557
|
+
/** Whether the repository holding `dir` has Interlok's Copilot hook file. */
|
|
558
|
+
async function copilotHookIn(dir) {
|
|
559
|
+
let d = resolve(dir);
|
|
560
|
+
for (;;) {
|
|
561
|
+
const f = join(d, COPILOT_HOOK_FILE);
|
|
562
|
+
if (existsSync(f)) {
|
|
563
|
+
try {
|
|
564
|
+
const t = await readFile(f, "utf8");
|
|
565
|
+
return t.includes(MARK) || t.includes("@interlok/cli hook");
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
if (existsSync(join(d, ".git")))
|
|
572
|
+
return false;
|
|
573
|
+
const up = dirname(d);
|
|
574
|
+
if (up === d)
|
|
575
|
+
return false;
|
|
576
|
+
d = up;
|
|
577
|
+
}
|
|
578
|
+
}
|
package/dist/index.d.ts
ADDED