@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/dist/gate.js ADDED
@@ -0,0 +1,338 @@
1
+ import { execSync } from "node:child_process";
2
+ import { readFileSync, writeFileSync, existsSync, appendFileSync } from "node:fs";
3
+ import { createHash } from "node:crypto";
4
+ import { canonical } from "./canonical.js";
5
+ import { summarizePlan, EXIT, MODEL_CHANGE_KINDS } from "./packs.js";
6
+ /**
7
+ * `interlok gate`: the Change Gate CI step. Collects context from the CI environment and git,
8
+ * summarizes an optional Terraform plan locally (the full plan never leaves CI), asks Interlok,
9
+ * and waits for a person if the change is held. Exit codes: 0 allowed/released · 10 denied ·
10
+ * 11 hold expired · 12 unreachable · 13 config or auth error.
11
+ */
12
+ const STATE = ".interlok-gate.json";
13
+ const env = process.env;
14
+ async function ciToken(api) {
15
+ if (env.INTERLOK_API_KEY)
16
+ return env.INTERLOK_API_KEY;
17
+ let idToken = env.INTERLOK_OIDC_TOKEN;
18
+ if (!idToken && env.ACTIONS_ID_TOKEN_REQUEST_URL && env.ACTIONS_ID_TOKEN_REQUEST_TOKEN) {
19
+ const r = await fetch(`${env.ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${encodeURIComponent("https://interlok.io")}`, {
20
+ headers: { authorization: `Bearer ${env.ACTIONS_ID_TOKEN_REQUEST_TOKEN}` },
21
+ });
22
+ if (!r.ok)
23
+ throw Object.assign(new Error(`could not get the GitHub OIDC token (${r.status}); add permissions: id-token: write`), { code: EXIT.config });
24
+ idToken = (await r.json()).value;
25
+ }
26
+ if (!idToken)
27
+ throw Object.assign(new Error("no INTERLOK_API_KEY and no CI OIDC token available"), {
28
+ code: EXIT.config,
29
+ });
30
+ const connector_id = env.INTERLOK_CONNECTOR;
31
+ if (!connector_id || !/^[0-9a-f-]{36}$/i.test(connector_id))
32
+ throw Object.assign(new Error("set INTERLOK_CONNECTOR to your connector id (Console → Settings → Connectors)"), { code: EXIT.config });
33
+ const x = await fetch(`${api}/api/v1/auth/oidc-exchange`, {
34
+ method: "POST",
35
+ headers: { "content-type": "application/json" },
36
+ body: JSON.stringify({ id_token: idToken, connector_id }),
37
+ });
38
+ if (!x.ok)
39
+ throw Object.assign(new Error(`OIDC exchange refused (${x.status}): ${(await x.text()).slice(0, 200)}`), { code: EXIT.config });
40
+ return (await x.json()).token;
41
+ }
42
+ const sh = (cmd) => {
43
+ try {
44
+ return execSync(cmd, { stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" }).trim();
45
+ }
46
+ catch {
47
+ return "";
48
+ }
49
+ };
50
+ const BOT = /\[bot\]$|^(dependabot|renovate|github-actions)/i;
51
+ const AGENT_TRAILER = /co-authored-by:.*\b(claude|copilot|cursor|devin|codex|openhands|aider|sweep|agent|bot)\b/i;
52
+ export function collectContext(agentIds = []) {
53
+ let pr = null;
54
+ let prAuthor = null;
55
+ let prAuthorBot = false;
56
+ if (env.GITHUB_EVENT_PATH && existsSync(env.GITHUB_EVENT_PATH)) {
57
+ try {
58
+ const ev = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, "utf8"));
59
+ pr = ev.pull_request?.number ?? ev.number ?? null;
60
+ prAuthor = ev.pull_request?.user?.login ?? null;
61
+ prAuthorBot = ev.pull_request?.user?.type === "Bot";
62
+ }
63
+ catch {
64
+ /* no event payload */
65
+ }
66
+ }
67
+ if (pr === null) {
68
+ const m = /^refs\/pull\/(\d+)\//.exec(env.GITHUB_REF ?? "");
69
+ if (m)
70
+ pr = Number(m[1]);
71
+ }
72
+ const base = env.GITHUB_BASE_REF ? `origin/${env.GITHUB_BASE_REF}...HEAD` : "HEAD~1";
73
+ const changed = sh(`git diff --name-only ${base}`).split("\n").filter(Boolean);
74
+ const authors = sh(`git log --format=%an%x1f%ae -n 50 ${env.GITHUB_BASE_REF ? `origin/${env.GITHUB_BASE_REF}..HEAD` : "-1"}`)
75
+ .split("\n")
76
+ .filter(Boolean);
77
+ const bodies = sh(`git log --format=%B -n 20 ${env.GITHUB_BASE_REF ? `origin/${env.GITHUB_BASE_REF}..HEAD` : "-1"}`);
78
+ const kinds = new Set();
79
+ const ids = new Set();
80
+ const requester = env.GITHUB_ACTOR ?? env.GITLAB_USER_LOGIN ?? null;
81
+ const isAgent = (s) => agentIds.some((a) => a && s.toLowerCase().includes(a.toLowerCase()));
82
+ const classify = (name, email) => isAgent(name) || isAgent(email)
83
+ ? "agent"
84
+ : BOT.test(name) || /noreply.*bot|bot@/i.test(email)
85
+ ? "bot"
86
+ : "human";
87
+ if (prAuthor) {
88
+ kinds.add(prAuthorBot ? "bot" : classify(prAuthor, ""));
89
+ ids.add(prAuthor);
90
+ }
91
+ else if (requester)
92
+ kinds.add(classify(requester, ""));
93
+ for (const a of authors) {
94
+ const [name = "", email = ""] = a.split("\x1f");
95
+ kinds.add(classify(name, email));
96
+ if (name)
97
+ ids.add(name);
98
+ if (email)
99
+ ids.add(email);
100
+ }
101
+ if (AGENT_TRAILER.test(bodies))
102
+ kinds.add("agent");
103
+ return {
104
+ repo: env.GITHUB_REPOSITORY ??
105
+ env.CI_PROJECT_PATH ??
106
+ (sh("git config --get remote.origin.url").replace(/^.*[:/]([^/]+\/[^/]+?)(\.git)?$/, "$1") ||
107
+ null),
108
+ ref: env.GITHUB_HEAD_REF ||
109
+ env.GITHUB_REF_NAME ||
110
+ env.CI_COMMIT_REF_NAME ||
111
+ sh("git rev-parse --abbrev-ref HEAD") ||
112
+ null,
113
+ commit_sha: env.GITHUB_SHA ?? env.CI_COMMIT_SHA ?? (sh("git rev-parse HEAD") || null),
114
+ pr_number: pr,
115
+ requester,
116
+ author_kinds: [...kinds],
117
+ author_ids: [...ids].slice(0, 50),
118
+ changed_paths: changed.slice(0, 2000),
119
+ files_changed: changed.length,
120
+ source_ref: env.GITHUB_RUN_ID
121
+ ? `gh:${env.GITHUB_REPOSITORY}:${env.GITHUB_RUN_ID}:${env.GITHUB_RUN_ATTEMPT ?? 1}`
122
+ : env.CI_JOB_ID
123
+ ? `gl:${env.CI_PROJECT_PATH}:${env.CI_JOB_ID}`
124
+ : `local:${Date.now()}`,
125
+ };
126
+ }
127
+ export function planFromFile(path) {
128
+ const plan = JSON.parse(readFileSync(path, "utf8"));
129
+ const summary = summarizePlan(plan);
130
+ const rc = Array.isArray(plan?.resource_changes) ? plan.resource_changes : [];
131
+ const material = rc.map((r) => ({
132
+ address: r.address,
133
+ actions: r?.change?.actions ?? [],
134
+ after: r?.change?.after ?? null,
135
+ }));
136
+ const plan_hash = createHash("sha256").update(canonical(material)).digest("hex");
137
+ return { summary, plan_hash };
138
+ }
139
+ const list = (s) => (s ?? "")
140
+ .split(",")
141
+ .map((x) => x.trim())
142
+ .filter(Boolean);
143
+ /** `--action model.promote` flags → the model description the Change Gate judges. */
144
+ export function modelFromFlags(value) {
145
+ const model_to = value("--model-to");
146
+ if (!model_to)
147
+ return "model.promote needs --model-to";
148
+ const changes = list(value("--model-change"));
149
+ const bad = changes.filter((c) => !MODEL_CHANGE_KINDS.includes(c));
150
+ if (bad.length)
151
+ return `--model-change accepts ${MODEL_CHANGE_KINDS.join(", ")}; got ${bad.join(", ")}`;
152
+ for (const k of ["--digest-from", "--digest-to"])
153
+ if (value(k) && !/^[0-9a-f]{64}$/.test(value(k)))
154
+ return `${k} must be a sha256 (64 hex)`;
155
+ const passed = value("--eval-passed");
156
+ const regressions = value("--eval-regressions");
157
+ const out = {
158
+ model_to,
159
+ model_from: value("--model-from"),
160
+ provider_from: value("--provider-from"),
161
+ provider_to: value("--provider-to"),
162
+ digest_from: value("--digest-from"),
163
+ digest_to: value("--digest-to"),
164
+ changes,
165
+ classification: list(value("--classification")),
166
+ };
167
+ if (passed !== undefined || regressions !== undefined || value("--eval-suite"))
168
+ out.eval = {
169
+ suite: value("--eval-suite") ?? null,
170
+ passed: passed === undefined ? null : passed === "true" || passed === "1",
171
+ regressions: regressions === undefined ? null : Math.max(0, Number(regressions) || 0),
172
+ };
173
+ for (const k of Object.keys(out))
174
+ if (out[k] === undefined)
175
+ delete out[k];
176
+ return out;
177
+ }
178
+ function parseWait(s) {
179
+ if (!s)
180
+ return 3600;
181
+ const m = /^(\d+)\s*(s|m|h)?$/.exec(s.trim());
182
+ if (!m)
183
+ return 3600;
184
+ const n = Number(m[1]);
185
+ return m[2] === "h" ? n * 3600 : m[2] === "s" ? n : n * 60;
186
+ }
187
+ export async function gateCommand(args, value, has) {
188
+ const api = (env.INTERLOK_URL ?? value("--url") ?? "https://interlok.io").replace(/\/$/, "");
189
+ // Tokens travel to this URL: refuse plain http except to a local development server.
190
+ if (!/^https:\/\//.test(api) && !/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(api)) {
191
+ console.error("INTERLOK_URL must be https");
192
+ process.exitCode = EXIT.config;
193
+ return;
194
+ }
195
+ const fail = (code, msg) => {
196
+ console.error(msg);
197
+ process.exitCode = code;
198
+ };
199
+ if (args[0] === "report") {
200
+ if (!existsSync(STATE))
201
+ return fail(EXIT.config, "no gate state found; run `interlok gate` first in this job");
202
+ const st = JSON.parse(readFileSync(STATE, "utf8"));
203
+ const status = value("--status") ?? "unknown";
204
+ const outcome = status === "success"
205
+ ? "success"
206
+ : status === "failure" || status === "cancelled"
207
+ ? "failure"
208
+ : "unknown";
209
+ try {
210
+ const token = await ciToken(api);
211
+ const r = await fetch(`${api}/api/v1/change-requests/${st.id}`, {
212
+ method: "POST",
213
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
214
+ body: JSON.stringify({ outcome }),
215
+ });
216
+ console.log(r.ok ? `outcome recorded: ${outcome}` : `could not record the outcome (${r.status})`);
217
+ }
218
+ catch (e) {
219
+ return fail(e?.code ?? EXIT.unreachable, e.message);
220
+ }
221
+ return;
222
+ }
223
+ const action = (value("--action") ?? "deploy");
224
+ if (!["deploy", "terraform.apply", "model.promote"].includes(action))
225
+ return fail(EXIT.config, "--action must be deploy, terraform.apply or model.promote");
226
+ const ctx = collectContext((value("--agents") ?? "")
227
+ .split(",")
228
+ .map((s) => s.trim())
229
+ .filter(Boolean));
230
+ const body = {
231
+ ...ctx,
232
+ action,
233
+ environment: value("--env") ?? env.GITHUB_ENVIRONMENT ?? null,
234
+ deadline_seconds: Math.min(parseWait(value("--wait")), 86_400),
235
+ };
236
+ if (value("--plan")) {
237
+ try {
238
+ const p = planFromFile(value("--plan"));
239
+ body.plan = p.summary;
240
+ body.plan_hash = p.plan_hash;
241
+ }
242
+ catch (e) {
243
+ return fail(EXIT.config, `could not read the plan: ${e.message}`);
244
+ }
245
+ }
246
+ if (action === "model.promote") {
247
+ const m = modelFromFlags(value);
248
+ if (typeof m === "string")
249
+ return fail(EXIT.config, m);
250
+ body.model = m;
251
+ }
252
+ if (has("--emergency")) {
253
+ const reason = value("--reason");
254
+ if (!reason)
255
+ return fail(EXIT.config, "--emergency needs --reason");
256
+ body.emergency_reason = reason;
257
+ }
258
+ for (const k of Object.keys(body))
259
+ if (body[k] === null || body[k] === undefined)
260
+ delete body[k];
261
+ let token;
262
+ try {
263
+ token = await ciToken(api);
264
+ }
265
+ catch (e) {
266
+ return fail(e?.code ?? EXIT.config, e.message);
267
+ }
268
+ const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" };
269
+ let r;
270
+ try {
271
+ const res = await fetch(`${api}/api/v1/change-requests`, {
272
+ method: "POST",
273
+ headers,
274
+ body: JSON.stringify(body),
275
+ });
276
+ if (res.status === 401 || res.status === 403)
277
+ return fail(EXIT.config, `Interlok refused the request (${res.status}): ${(await res.text()).slice(0, 200)}`);
278
+ if (!res.ok)
279
+ return fail(EXIT.unreachable, `Interlok answered ${res.status}`);
280
+ r = await res.json();
281
+ }
282
+ catch (e) {
283
+ if (has("--fail-open")) {
284
+ console.error(`Interlok unreachable (${e.message}); --fail-open set, passing. This cannot be recorded in the ledger.`);
285
+ return;
286
+ }
287
+ return fail(EXIT.unreachable, `Interlok unreachable: ${e.message}`);
288
+ }
289
+ // The id is written into files GitHub reads as commands: accept only a UUID.
290
+ if (typeof r.id !== "string" ||
291
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(r.id))
292
+ return fail(EXIT.unreachable, "Interlok returned an unexpected response");
293
+ writeFileSync(STATE, JSON.stringify({ id: r.id, at: new Date().toISOString() }));
294
+ if (env.GITHUB_ENV)
295
+ appendFileSync(env.GITHUB_ENV, `INTERLOK_CHANGE_ID=${r.id}\n`);
296
+ const line = (s) => console.log(`interlok gate: ${s}`);
297
+ if (r.status === "auto_allowed")
298
+ return line(`allowed (${r.risk_points ?? 0} points)`);
299
+ if (r.status === "would_hold")
300
+ return line(`shadow mode: would have been held (${(r.rules_fired ?? []).join(", ")}); passing`);
301
+ if (r.status === "emergency")
302
+ return line(`emergency change allowed; a second person must review within 24 h · ${r.console_url}`);
303
+ if (r.status === "approved")
304
+ return line("approved (plan already approved by a person)");
305
+ if (r.status !== "held")
306
+ return fail(EXIT.rejected, `${r.status}`);
307
+ line(`HELD: ${(r.rules_fired ?? []).join(", ")}`);
308
+ line(`waiting for a person · ${r.hold_url ?? r.console_url}`);
309
+ const safeRules = (r.rules_fired ?? [])
310
+ .map((x) => String(x).replace(/[^A-Z0-9-]/g, ""))
311
+ .join(", ");
312
+ const link = String(r.hold_url ?? r.console_url ?? "");
313
+ if (env.GITHUB_STEP_SUMMARY && /^https?:\/\/[^\s()<>[\]]+$/.test(link))
314
+ appendFileSync(env.GITHUB_STEP_SUMMARY, `### Interlok gate: held\n${safeRules} · [Release or deny](${link})\n`);
315
+ const deadline = Date.parse(r.deadline_at);
316
+ for (;;) {
317
+ let s;
318
+ try {
319
+ const res = await fetch(`${api}/api/v1/change-requests/${r.id}?wait=25`, { headers });
320
+ if (!res.ok)
321
+ throw new Error(`HTTP ${res.status}`);
322
+ s = await res.json();
323
+ }
324
+ catch (e) {
325
+ return fail(EXIT.unreachable, `lost contact with Interlok while waiting: ${e.message}`);
326
+ }
327
+ if (s.status === "approved")
328
+ return line("released by a person");
329
+ if (s.status === "failed_open")
330
+ return line("hold expired; connector fails open, passing (flagged in the ledger)");
331
+ if (s.status === "rejected")
332
+ return fail(EXIT.rejected, "denied by a person");
333
+ if (s.status === "expired")
334
+ return fail(EXIT.expired, "hold expired with no determination");
335
+ if (Date.now() > deadline + 60_000)
336
+ return fail(EXIT.expired, "deadline passed");
337
+ }
338
+ }
package/dist/hook.d.ts ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * `interlok hook`: a pre-tool-use hook for coding agents (Claude Code, Codex, Cursor).
3
+ *
4
+ * Every shell command and MCP call the agent is about to run is classified HERE, against a policy
5
+ * file, with no network call. Only a call that matches a rule leaves the machine: it becomes an
6
+ * Interlok approval that a named person must answer. Low-risk work is never gated.
7
+ *
8
+ * Rules this file keeps:
9
+ * - Nothing here can approve. It asks the Approvals API and acts on a person's determination.
10
+ * - Fail closed: a matched call with no key, no network or no answer is refused (--fail-open
11
+ * relaxes the network case only).
12
+ * - An approval is single-use. The hook remembers which approval it opened for a command and
13
+ * consumes it the first time it lets that command run; the same command later asks again.
14
+ * - The command line shown to the approver is scrubbed of credentials; `"summary": "hash-only"`
15
+ * sends the rule name and a hash instead. The full tool input never leaves the machine.
16
+ */
17
+ export type Agent = "claude" | "codex" | "cursor" | "gemini" | "copilot";
18
+ /**
19
+ * Seconds each agent's config gives the hook. Gemini CLI and GitHub Copilot let a call through when
20
+ * a hook times out, so the wait for a person is always capped well inside this.
21
+ */
22
+ export declare const HOOK_TIMEOUT_S = 120;
23
+ export declare const MAX_WAIT_S: number;
24
+ export interface Rule {
25
+ id: string;
26
+ /** Regex on the tool name. Shell commands are normalised to "Bash". Default: "^Bash$". */
27
+ tool?: string;
28
+ /** Regex on the command line (shell) or the JSON tool input (MCP). Omit to match any input. */
29
+ match?: string;
30
+ /** Regex that exempts an otherwise matching call. */
31
+ unless?: string;
32
+ /** What the approver reads before the command itself. */
33
+ label?: string;
34
+ approvers?: number;
35
+ }
36
+ export interface Policy {
37
+ version?: 1;
38
+ /** Include the built-in rules below (default true). */
39
+ defaults?: boolean;
40
+ environment?: string;
41
+ /** Seconds to wait for a person before answering "held". */
42
+ wait?: number;
43
+ /** "command" shows the scrubbed command line; "hash-only" shows only the rule and a hash. */
44
+ summary?: "command" | "hash-only";
45
+ rules?: Rule[];
46
+ }
47
+ /** Built-in rules: the actions that leave the worktree and are hard to take back. */
48
+ export declare const DEFAULT_RULES: Rule[];
49
+ export interface Call {
50
+ agent: Agent;
51
+ /** "Bash" for shell commands, `mcp__<server>__<tool>` for MCP calls. */
52
+ tool: string;
53
+ /** Shell: the command line. MCP: the tool input. */
54
+ input: unknown;
55
+ command?: string;
56
+ session: string;
57
+ cwd: string;
58
+ }
59
+ /** Read the agent's stdin payload into one shape. Returns null for anything the hook ignores. */
60
+ export declare function parseCall(agent: Agent, raw: any): Call | null;
61
+ /** The first rule a call matches, or null. Pure: no network, no file access. */
62
+ export declare function matchRule(policy: Policy, call: Call): Rule | null;
63
+ /** Strip credentials from a command line before an approver (and Interlok) sees it. */
64
+ export declare function scrub(s: string): string;
65
+ export declare const payloadHash: (call: Call) => string;
66
+ export type Verdict = {
67
+ allow: true;
68
+ } | {
69
+ allow: false;
70
+ reason: string;
71
+ };
72
+ /** What the agent receives on stdout. `null` means print nothing (allow). */
73
+ export declare function render(agent: Agent, v: Verdict): string | null;
74
+ /**
75
+ * Process exit code for a verdict. Copilot denies a tool call on any non-zero exit, whatever stdout
76
+ * says, so a refusal exits 2 there as well; every other agent reads the JSON and exits 0.
77
+ */
78
+ export declare function exitCode(agent: Agent, v: Verdict): number;
79
+ /**
80
+ * GitHub Copilot also runs the hooks in a repository's .claude/settings.json, with its own payload
81
+ * (no transcript_path, an ISO timestamp). When the repository has Interlok's Copilot hook too, the
82
+ * Claude entry steps aside so one tool call opens one request, not two.
83
+ */
84
+ export declare function deferToCopilotHook(agent: Agent, raw: any, copilotHookInRepo: boolean): boolean;
85
+ export interface HookDeps {
86
+ api: string;
87
+ key: string;
88
+ agentName: string;
89
+ waitSeconds: number;
90
+ failOpen: boolean;
91
+ stateDir: string;
92
+ fetch?: typeof fetch;
93
+ sleep?: (ms: number) => Promise<void>;
94
+ now?: () => number;
95
+ context?: Record<string, string>;
96
+ }
97
+ /** Decide one call. Every path that is not a person's approval returns a refusal. */
98
+ export declare function decide(call: Call, policy: Policy, deps: HookDeps): Promise<Verdict>;
99
+ /** Nearest .interlok/policy.json walking up from `dir` (stops at the filesystem root). */
100
+ export declare function loadPolicy(dir: string): Promise<{
101
+ policy: Policy;
102
+ file: string | null;
103
+ }>;
104
+ export declare const DEFAULT_POLICY_FILE: Policy;
105
+ export declare const COPILOT_HOOK_FILE: string;
106
+ /** Merge Interlok's hook into each agent's project config. Idempotent; never removes entries. */
107
+ export declare function init(dir: string, agents: Agent[], bin: string): Promise<string[]>;
108
+ export declare function hookCommand(args: string[], value: (flag: string) => string | undefined, has: (flag: string) => boolean): Promise<void>;