@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/LICENSE
ADDED
package/README.md
CHANGED
|
@@ -1,3 +1,159 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Interlok CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Seven commands: `scan`, `shadow`, `proxy`, `gate`, `verify`, `approvals-mcp`, `hook`. Apache-2.0. Telemetry is off; nothing leaves your machine unless you pass `--publish`, run `shadow`, or an agent asks a person through `hook` or `approvals-mcp`.
|
|
4
|
+
|
|
5
|
+
## scan: coverage, locally
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @interlok/cli scan # reads .mcp.json, .vscode/mcp.json, .cursor/mcp.json
|
|
9
|
+
npx @interlok/cli scan .mcp.json --enumerate # starts each server and lists its tools
|
|
10
|
+
npx @interlok/cli scan --ci --min-coverage 80 # exit 5 below 80% of high-risk write paths guarded
|
|
11
|
+
npx @interlok/cli scan --publish # signed public report + README badge (needs a key)
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Coverage counts only high-consequence write paths (money, access, deletion, code execution, deploys, messages to people) and how many have a guard. Tools whose names can't be classified count as high-risk until someone classifies them. `src/classify.ts` is the same file the hosted app uses; a test fails the build if they differ.
|
|
15
|
+
|
|
16
|
+
## shadow: what your agent would have done, on every pull request
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx @interlok/cli shadow --agent my-agent --markdown interlok.md \
|
|
20
|
+
--fail-on new_high_reach,coverage_drop -- npm run agent:e2e
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
In GitHub Actions the commit, branch, and base branch are read from the environment, so a pull request is compared with the latest run on the branch it merges into. `--fail-on` takes `would_hold`, `new_high_reach`, `coverage_drop` (exit 4). The Markdown also goes to the Actions run summary. A complete workflow is in `docs/ci/interlok-shadow.yml`.
|
|
24
|
+
|
|
25
|
+
## proxy: put any MCP server behind Interlok, no code
|
|
26
|
+
|
|
27
|
+
In your MCP client config (Claude Desktop, Cursor, VS Code, your own agent), wrap the server's command:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"mcpServers": {
|
|
32
|
+
"github": {
|
|
33
|
+
"command": "npx",
|
|
34
|
+
"args": [
|
|
35
|
+
"-y",
|
|
36
|
+
"@interlok/cli",
|
|
37
|
+
"proxy",
|
|
38
|
+
"--name",
|
|
39
|
+
"github",
|
|
40
|
+
"--agent",
|
|
41
|
+
"support-bot",
|
|
42
|
+
"--",
|
|
43
|
+
"npx",
|
|
44
|
+
"-y",
|
|
45
|
+
"@modelcontextprotocol/server-github"
|
|
46
|
+
],
|
|
47
|
+
"env": { "INTERLOK_API_KEY": "ilk_live_…", "GITHUB_PERSONAL_ACCESS_TOKEN": "…" }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
For a remote server, run `npx @interlok/cli proxy --name crm --upstream https://mcp.example.com/mcp --port 8787` and point the client at `http://127.0.0.1:8787/mcp`. Cloud agent builders (Copilot Studio, Agentforce, AgentCore Gateway) cannot reach loopback: run the proxy behind your HTTPS ingress, add `--host 0.0.0.0` inside a container, and register the ingress URL as the MCP server. The client's own credentials still go to your server; the proxy adds none and forwards none of Interlok's.
|
|
54
|
+
|
|
55
|
+
- **Gate (default):** every `tools/call` is asked of the gate first. Allowed calls run; a held call waits for a person (up to `--wait`, default 300 s) and runs only if released; a denied call never reaches the server and the model gets the reason. If Interlok can't be reached the call is refused unless you pass `--fail-open`.
|
|
56
|
+
- **Shadow:** under `interlok shadow -- <your test command>` (or with `--shadow <run>`), every call is recorded into the run and forwarded; nothing is blocked.
|
|
57
|
+
- Everything else (initialize, tools/list, resources, pings) passes through untouched. The server's tool list is registered once so description changes show up as drift.
|
|
58
|
+
- Your Interlok key is never passed to the MCP server, and the proxy writes only protocol messages to stdout.
|
|
59
|
+
|
|
60
|
+
## hook: hold a coding agent's risky commands for a person
|
|
61
|
+
|
|
62
|
+
For Claude Code, Codex, Cursor, Gemini CLI and GitHub Copilot (the Copilot CLI, the Copilot cloud
|
|
63
|
+
agent and VS Code), including inside Paradev and Conductor workspaces. Each shell
|
|
64
|
+
command and MCP call is checked locally against `.interlok/policy.json`; only a match leaves the
|
|
65
|
+
machine, as an approval a named person answers.
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npm i -g @interlok/cli
|
|
69
|
+
interlok hook init [--agent claude,codex,cursor,gemini,copilot] [--npx]
|
|
70
|
+
export INTERLOK_API_KEY=ilk_... # approvals:write scope
|
|
71
|
+
interlok hook test "vercel deploy --prod" # which rule would hold it, without running anything
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`init` writes the policy file (built-in rules: protected-branch and force pushes, PR merges,
|
|
75
|
+
production deploys, infrastructure applies, migrations and destructive SQL, publishes, secret
|
|
76
|
+
changes, deletes outside the project, MCP tools that change something) and merges the hook into
|
|
77
|
+
`.claude/settings.json`, `.codex/hooks.json`, `.cursor/hooks.json`, `.gemini/settings.json` and
|
|
78
|
+
`.github/hooks/interlok.json` without touching what is there. Add rules with `id`, `match` (regex on the command line), optional `tool`, `unless` and
|
|
79
|
+
`approvers`; set `"defaults": false` to use only your own.
|
|
80
|
+
|
|
81
|
+
- Approvals are single-use: the same command later asks again.
|
|
82
|
+
- Still held after `wait` seconds: the agent is told to continue other work and retry the exact
|
|
83
|
+
command; the retry resolves the same request instead of opening a new one.
|
|
84
|
+
- Fails closed with no key, no connection or an unreadable policy; `--fail-open` relaxes only an
|
|
85
|
+
unreachable Interlok.
|
|
86
|
+
- Credentials in a command line are masked before anyone sees it; `"summary": "hash-only"` sends
|
|
87
|
+
only the rule and a hash.
|
|
88
|
+
- Gemini CLI and Copilot let a call run when a hook times out, so every config gives the hook 120
|
|
89
|
+
seconds and the wait for a person is capped at 100.
|
|
90
|
+
- Copilot: commit `.github/hooks/interlok.json` (the cloud agent reads only that folder) and put
|
|
91
|
+
`INTERLOK_API_KEY` in the repository's `copilot` environment secrets. Copilot also runs the hooks
|
|
92
|
+
in `.claude/settings.json`; with both files present, the Claude entry steps aside for Copilot so
|
|
93
|
+
one command opens one request.
|
|
94
|
+
|
|
95
|
+
## approvals-mcp: let any MCP agent ask a person first
|
|
96
|
+
|
|
97
|
+
One tool, `request_human_approval`, for agents that should check with a person before they act. No gateway and no policy: the request always waits, a named person approves or denies it in the Interlok console, and the agent gets the answer with a signed receipt. Only `approved` means go ahead; anything else, including Interlok being unreachable, tells the agent not to act.
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
{
|
|
101
|
+
"mcpServers": {
|
|
102
|
+
"interlok-approvals": {
|
|
103
|
+
"command": "npx",
|
|
104
|
+
"args": ["-y", "@interlok/cli", "approvals-mcp", "--agent", "support-bot"],
|
|
105
|
+
"env": { "INTERLOK_API_KEY": "ilk_live_…" }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
- The `payload` argument is hashed on your machine; only its SHA-256 reaches Interlok.
|
|
112
|
+
- `--wait <seconds>` (default 120) is how long the tool waits for a person before answering "still waiting".
|
|
113
|
+
- The key needs the `approvals:write` and `approvals:read` scopes. A key can ask; it can never answer.
|
|
114
|
+
|
|
115
|
+
## gate: hold a risky deploy for a person
|
|
116
|
+
|
|
117
|
+
```yaml
|
|
118
|
+
jobs:
|
|
119
|
+
gate:
|
|
120
|
+
permissions: { id-token: write, contents: read }
|
|
121
|
+
steps:
|
|
122
|
+
- uses: actions/checkout@v4
|
|
123
|
+
with: { fetch-depth: 0 }
|
|
124
|
+
- run: terraform show -json tfplan > plan.json # optional
|
|
125
|
+
- run: npx @interlok/cli gate --action deploy --env production --plan plan.json --wait 60m
|
|
126
|
+
env: { INTERLOK_CONNECTOR: <connector id from Console → Settings → Connectors> }
|
|
127
|
+
deploy:
|
|
128
|
+
needs: gate
|
|
129
|
+
steps:
|
|
130
|
+
# ... deploy ...
|
|
131
|
+
- if: always()
|
|
132
|
+
run: npx @interlok/cli gate report --status ${{ job.status }}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
CI authenticates with its OIDC token; nothing is stored. The plan is summarized locally and only the summary and its hash are sent. Low-risk changes pass in seconds; a held change waits for a person in Slack or the console. `--emergency --reason "..."` is break-glass, allowed only for people on the connector's emergency list and never for agent- or bot-authored changes; a second person reviews within 24 h. Protect the workflow file itself (CODEOWNERS and branch protection): a CI gate is only as strong as the workflow that calls it. Exit codes: 0 allowed or released · 10 denied · 11 hold expired · 12 Interlok unreachable (fails closed unless `--fail-open`) · 13 config or auth error.
|
|
136
|
+
|
|
137
|
+
### Model promotions
|
|
138
|
+
|
|
139
|
+
The same step holds a production model change for a person: a provider swap, new weights, a floating alias (`latest`, `preview`), a failed or missing eval, or a model that serves governed data.
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
npx @interlok/cli gate --action model.promote --env production \
|
|
143
|
+
--provider-from openai --provider-to aws.bedrock \
|
|
144
|
+
--model-from gpt-4.1-2025-04-14 --model-to anthropic.claude-sonnet-4-5-20250929-v1:0 \
|
|
145
|
+
--model-change provider,model --eval-suite regression-v3 --eval-passed true \
|
|
146
|
+
--classification cui
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`--model-change` takes any of `model, provider, weights, fine_tune, quantization, prompt_template, decoding, alias`. `--digest-from/--digest-to` take the sha256 of weights or an image when your platform can state one.
|
|
150
|
+
|
|
151
|
+
## verify: offline, no account
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
npx @interlok/cli verify interlok-shadow-<run>.json
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Checks every signature and that the events, coverage, and run diff are exactly what was signed. Public reports (`/r/<slug>`) verify the same way.
|
|
158
|
+
|
|
159
|
+
Exit codes: 1 error · 2 critical finding · 3 verification failed · 4 `--fail-on` tripped · 5 below `--min-coverage`.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `interlok approvals-mcp`: an MCP server with one tool, `request_human_approval`. Any MCP agent
|
|
3
|
+
* (Claude, Cursor, Copilot, your own) can ask a named person before it does something it should
|
|
4
|
+
* not do alone. The request waits in the Interlok console approvals queue (or a connected channel); only a
|
|
5
|
+
* person can answer it, and the answer comes back with a signed receipt.
|
|
6
|
+
*
|
|
7
|
+
* { "mcpServers": { "interlok-approvals": { "command": "npx",
|
|
8
|
+
* "args": ["-y", "@interlok/cli", "approvals-mcp"],
|
|
9
|
+
* "env": { "INTERLOK_API_KEY": "ilk_..." } } } }
|
|
10
|
+
*
|
|
11
|
+
* The payload is hashed here and never sent. Protocol messages go to stdout; everything else to
|
|
12
|
+
* stderr. Nothing in this file can approve: the API refuses determinations from a key.
|
|
13
|
+
*/
|
|
14
|
+
export declare const TOOL: {
|
|
15
|
+
readonly name: "request_human_approval";
|
|
16
|
+
readonly description: "Ask a named person to approve an action before you take it. Use this before anything irreversible or consequential: paying, refunding, deleting, deploying, emailing customers, changing permissions. Wait for the answer and act only if it is approved.";
|
|
17
|
+
readonly inputSchema: {
|
|
18
|
+
readonly type: "object";
|
|
19
|
+
readonly properties: {
|
|
20
|
+
readonly action: {
|
|
21
|
+
readonly type: "string";
|
|
22
|
+
readonly description: "Short machine name of what you want to do, e.g. refund.issue or db.drop_table.";
|
|
23
|
+
};
|
|
24
|
+
readonly summary: {
|
|
25
|
+
readonly type: "string";
|
|
26
|
+
readonly description: "One or two sentences the approver reads: what, to whom, how much, and why.";
|
|
27
|
+
};
|
|
28
|
+
readonly payload: {
|
|
29
|
+
readonly description: "The exact request you will send if approved. Only its SHA-256 leaves this machine.";
|
|
30
|
+
};
|
|
31
|
+
readonly context: {
|
|
32
|
+
readonly type: "object";
|
|
33
|
+
readonly description: "Up to 20 short fields shown to the approver (strings, numbers, booleans).";
|
|
34
|
+
readonly additionalProperties: {
|
|
35
|
+
readonly type: readonly ["string", "number", "boolean"];
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
readonly required: readonly ["action", "summary"];
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
export interface McpConfig {
|
|
43
|
+
api: string;
|
|
44
|
+
key: string;
|
|
45
|
+
agent: string;
|
|
46
|
+
/** How long to wait for a person before answering "still waiting". */
|
|
47
|
+
waitSeconds: number;
|
|
48
|
+
fetch?: typeof fetch;
|
|
49
|
+
sleep?: (ms: number) => Promise<void>;
|
|
50
|
+
log?: (line: string) => void;
|
|
51
|
+
}
|
|
52
|
+
type Msg = {
|
|
53
|
+
jsonrpc: "2.0";
|
|
54
|
+
id?: string | number | null;
|
|
55
|
+
method?: string;
|
|
56
|
+
params?: any;
|
|
57
|
+
};
|
|
58
|
+
export declare const payloadHash: (payload: unknown) => string;
|
|
59
|
+
/** What the model reads for each outcome. Only "approved" means go ahead. */
|
|
60
|
+
export declare function describe(a: {
|
|
61
|
+
status: string;
|
|
62
|
+
hold?: {
|
|
63
|
+
tag_number?: string | null;
|
|
64
|
+
};
|
|
65
|
+
determinations?: {
|
|
66
|
+
by: string;
|
|
67
|
+
note: string | null;
|
|
68
|
+
}[];
|
|
69
|
+
receipt?: {
|
|
70
|
+
entries?: {
|
|
71
|
+
seq: number;
|
|
72
|
+
entry_hash: string;
|
|
73
|
+
}[];
|
|
74
|
+
};
|
|
75
|
+
console_url?: string;
|
|
76
|
+
}): {
|
|
77
|
+
text: string;
|
|
78
|
+
isError: boolean;
|
|
79
|
+
};
|
|
80
|
+
/** Handle one JSON-RPC message. Returns the reply, or null for notifications. */
|
|
81
|
+
export declare function handle(m: Msg, cfg: McpConfig): Promise<object | null>;
|
|
82
|
+
export declare function approvalsMcpCommand(value: (flag: string) => string | undefined): Promise<void>;
|
|
83
|
+
export {};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import { canonical } from "./canonical.js";
|
|
4
|
+
/**
|
|
5
|
+
* `interlok approvals-mcp`: an MCP server with one tool, `request_human_approval`. Any MCP agent
|
|
6
|
+
* (Claude, Cursor, Copilot, your own) can ask a named person before it does something it should
|
|
7
|
+
* not do alone. The request waits in the Interlok console approvals queue (or a connected channel); only a
|
|
8
|
+
* person can answer it, and the answer comes back with a signed receipt.
|
|
9
|
+
*
|
|
10
|
+
* { "mcpServers": { "interlok-approvals": { "command": "npx",
|
|
11
|
+
* "args": ["-y", "@interlok/cli", "approvals-mcp"],
|
|
12
|
+
* "env": { "INTERLOK_API_KEY": "ilk_..." } } } }
|
|
13
|
+
*
|
|
14
|
+
* The payload is hashed here and never sent. Protocol messages go to stdout; everything else to
|
|
15
|
+
* stderr. Nothing in this file can approve: the API refuses determinations from a key.
|
|
16
|
+
*/
|
|
17
|
+
export const TOOL = {
|
|
18
|
+
name: "request_human_approval",
|
|
19
|
+
description: "Ask a named person to approve an action before you take it. Use this before anything irreversible or consequential: paying, refunding, deleting, deploying, emailing customers, changing permissions. Wait for the answer and act only if it is approved.",
|
|
20
|
+
inputSchema: {
|
|
21
|
+
type: "object",
|
|
22
|
+
properties: {
|
|
23
|
+
action: {
|
|
24
|
+
type: "string",
|
|
25
|
+
description: "Short machine name of what you want to do, e.g. refund.issue or db.drop_table.",
|
|
26
|
+
},
|
|
27
|
+
summary: {
|
|
28
|
+
type: "string",
|
|
29
|
+
description: "One or two sentences the approver reads: what, to whom, how much, and why.",
|
|
30
|
+
},
|
|
31
|
+
payload: {
|
|
32
|
+
description: "The exact request you will send if approved. Only its SHA-256 leaves this machine.",
|
|
33
|
+
},
|
|
34
|
+
context: {
|
|
35
|
+
type: "object",
|
|
36
|
+
description: "Up to 20 short fields shown to the approver (strings, numbers, booleans).",
|
|
37
|
+
additionalProperties: { type: ["string", "number", "boolean"] },
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
required: ["action", "summary"],
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
export const payloadHash = (payload) => createHash("sha256").update(canonical(payload)).digest("hex");
|
|
44
|
+
const text = (id, t, isError = false) => ({
|
|
45
|
+
jsonrpc: "2.0",
|
|
46
|
+
id,
|
|
47
|
+
result: { ...(isError ? { isError: true } : {}), content: [{ type: "text", text: t }] },
|
|
48
|
+
});
|
|
49
|
+
/** What the model reads for each outcome. Only "approved" means go ahead. */
|
|
50
|
+
export function describe(a) {
|
|
51
|
+
const tag = a.hold?.tag_number ? ` (tag ${a.hold.tag_number})` : "";
|
|
52
|
+
const who = (a.determinations ?? []).map((d) => d.by).join(", ");
|
|
53
|
+
const note = (a.determinations ?? []).find((d) => d.note)?.note;
|
|
54
|
+
const last = a.receipt?.entries?.at(-1);
|
|
55
|
+
const receipt = last ? ` Signed receipt: ledger entry #${last.seq} (${last.entry_hash}).` : "";
|
|
56
|
+
switch (a.status) {
|
|
57
|
+
case "approved":
|
|
58
|
+
return {
|
|
59
|
+
text: `APPROVED by ${who || "a person"}${tag}. You may proceed with exactly this request.${note ? ` Note: ${note}` : ""}${receipt}`,
|
|
60
|
+
isError: false,
|
|
61
|
+
};
|
|
62
|
+
case "denied":
|
|
63
|
+
return {
|
|
64
|
+
text: `DENIED by ${who || "a person"}${tag}. Do not take this action.${note ? ` Reason: ${note}` : ""}${receipt}`,
|
|
65
|
+
isError: true,
|
|
66
|
+
};
|
|
67
|
+
case "expired":
|
|
68
|
+
case "cancelled":
|
|
69
|
+
return {
|
|
70
|
+
text: `No approval${tag}: the request ${a.status}. Do not take this action.`,
|
|
71
|
+
isError: true,
|
|
72
|
+
};
|
|
73
|
+
default:
|
|
74
|
+
return {
|
|
75
|
+
text: `Still waiting for a person${tag}. Do not take this action yet.${a.console_url ? ` Approvers decide at ${a.console_url}.` : ""}`,
|
|
76
|
+
isError: true,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function call(cfg, path, init = {}) {
|
|
81
|
+
const r = await (cfg.fetch ?? fetch)(`${cfg.api}${path}`, {
|
|
82
|
+
...init,
|
|
83
|
+
headers: {
|
|
84
|
+
"content-type": "application/json",
|
|
85
|
+
authorization: `Bearer ${cfg.key}`,
|
|
86
|
+
...(init.headers ?? {}),
|
|
87
|
+
},
|
|
88
|
+
signal: AbortSignal.timeout(30_000),
|
|
89
|
+
});
|
|
90
|
+
const body = await r.json().catch(() => ({}));
|
|
91
|
+
if (!r.ok)
|
|
92
|
+
throw new Error(`Interlok ${r.status}: ${body?.error ?? "request failed"}`);
|
|
93
|
+
return body;
|
|
94
|
+
}
|
|
95
|
+
/** Handle one JSON-RPC message. Returns the reply, or null for notifications. */
|
|
96
|
+
export async function handle(m, cfg) {
|
|
97
|
+
if (m.id === undefined || m.id === null)
|
|
98
|
+
return null; // notifications need no answer
|
|
99
|
+
switch (m.method) {
|
|
100
|
+
case "initialize":
|
|
101
|
+
return {
|
|
102
|
+
jsonrpc: "2.0",
|
|
103
|
+
id: m.id,
|
|
104
|
+
result: {
|
|
105
|
+
protocolVersion: m.params?.protocolVersion ?? "2025-06-18",
|
|
106
|
+
capabilities: { tools: {} },
|
|
107
|
+
serverInfo: { name: "interlok-approvals", version: "1.0.0" },
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
case "ping":
|
|
111
|
+
return { jsonrpc: "2.0", id: m.id, result: {} };
|
|
112
|
+
case "tools/list":
|
|
113
|
+
return { jsonrpc: "2.0", id: m.id, result: { tools: [TOOL] } };
|
|
114
|
+
case "tools/call": {
|
|
115
|
+
if (m.params?.name !== TOOL.name)
|
|
116
|
+
return text(m.id, `Unknown tool ${m.params?.name}`, true);
|
|
117
|
+
const a = m.params?.arguments ?? {};
|
|
118
|
+
if (typeof a.action !== "string" || typeof a.summary !== "string")
|
|
119
|
+
return text(m.id, "request_human_approval needs an action and a summary.", true);
|
|
120
|
+
try {
|
|
121
|
+
const created = await call(cfg, "/api/v1/approvals", {
|
|
122
|
+
method: "POST",
|
|
123
|
+
body: JSON.stringify({
|
|
124
|
+
action: a.action,
|
|
125
|
+
summary: a.summary,
|
|
126
|
+
payload_hash: a.payload !== undefined ? payloadHash(a.payload) : undefined,
|
|
127
|
+
context: a.context,
|
|
128
|
+
agent: { name: cfg.agent },
|
|
129
|
+
}),
|
|
130
|
+
});
|
|
131
|
+
let current = created;
|
|
132
|
+
const sleep = cfg.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
133
|
+
const deadline = Date.now() + cfg.waitSeconds * 1000;
|
|
134
|
+
while (current.status === "held" && Date.now() < deadline) {
|
|
135
|
+
await sleep(2000);
|
|
136
|
+
current = await call(cfg, `/api/v1/approvals/${encodeURIComponent(created.id)}`);
|
|
137
|
+
}
|
|
138
|
+
const d = describe(current);
|
|
139
|
+
return text(m.id, d.text, d.isError);
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
cfg.log?.(`approval request failed: ${e.message}`);
|
|
143
|
+
// Fail closed: without an answer from a person, the action does not happen.
|
|
144
|
+
return text(m.id, `Could not reach Interlok (${e.message}). No approval: do not take this action.`, true);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
default:
|
|
148
|
+
return {
|
|
149
|
+
jsonrpc: "2.0",
|
|
150
|
+
id: m.id,
|
|
151
|
+
error: { code: -32601, message: `Unknown method ${m.method}` },
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
export async function approvalsMcpCommand(value) {
|
|
156
|
+
const cfg = {
|
|
157
|
+
api: (value("--api") ?? process.env.INTERLOK_URL ?? "https://interlok.io").replace(/\/$/, ""),
|
|
158
|
+
key: process.env.INTERLOK_API_KEY ?? "",
|
|
159
|
+
agent: value("--agent") ?? process.env.INTERLOK_AGENT ?? "mcp-agent",
|
|
160
|
+
waitSeconds: Number(value("--wait") ?? 120),
|
|
161
|
+
log: (l) => process.stderr.write(`[interlok] ${l}\n`),
|
|
162
|
+
};
|
|
163
|
+
if (!cfg.key) {
|
|
164
|
+
process.stderr.write("interlok approvals-mcp needs INTERLOK_API_KEY (approvals:write scope)\n");
|
|
165
|
+
process.exitCode = 1;
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const rl = createInterface({ input: process.stdin });
|
|
169
|
+
for await (const line of rl) {
|
|
170
|
+
if (!line.trim())
|
|
171
|
+
continue;
|
|
172
|
+
let m;
|
|
173
|
+
try {
|
|
174
|
+
m = JSON.parse(line);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
// Each message on its own: a request waiting on a person never blocks pings or other calls.
|
|
180
|
+
void handle(m, cfg).then((reply) => {
|
|
181
|
+
if (reply)
|
|
182
|
+
process.stdout.write(JSON.stringify(reply) + "\n");
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function canonical(value: unknown): string;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function canonical(value) {
|
|
2
|
+
if (value === null || typeof value === "boolean" || typeof value === "string")
|
|
3
|
+
return JSON.stringify(value);
|
|
4
|
+
if (typeof value === "number") {
|
|
5
|
+
if (!Number.isFinite(value))
|
|
6
|
+
throw new Error("non-finite number");
|
|
7
|
+
return JSON.stringify(value);
|
|
8
|
+
}
|
|
9
|
+
if (Array.isArray(value))
|
|
10
|
+
return `[${value.map(canonical).join(",")}]`;
|
|
11
|
+
if (typeof value === "object") {
|
|
12
|
+
const o = value;
|
|
13
|
+
return `{${Object.keys(o)
|
|
14
|
+
.filter((k) => o[k] !== undefined)
|
|
15
|
+
.sort()
|
|
16
|
+
.map((k) => `${JSON.stringify(k)}:${canonical(o[k])}`)
|
|
17
|
+
.join(",")}}`;
|
|
18
|
+
}
|
|
19
|
+
throw new Error(`unsupported type ${typeof value}`);
|
|
20
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool risk classifier and coverage math.
|
|
3
|
+
*
|
|
4
|
+
* Deterministic on purpose, like the posture score: the same tool must classify the same way on
|
|
5
|
+
* every machine, in the console, in CI, and in the gate. No model, no network, no clock.
|
|
6
|
+
*
|
|
7
|
+
* This file imports nothing. The public CLI vendors it byte-for-byte (cli/src/classify.ts) and a
|
|
8
|
+
* test fails the build if the two copies drift, so a CI coverage number and a console coverage
|
|
9
|
+
* number can never disagree about the same inventory.
|
|
10
|
+
*
|
|
11
|
+
* Precedence, highest first:
|
|
12
|
+
* 1. A human declaration (recorded in the ledger) wins outright.
|
|
13
|
+
* 2. The registry's write_path, when the tool is registered.
|
|
14
|
+
* 3. The higher of annotation risk and name/schema heuristics. MCP annotations come from the
|
|
15
|
+
* server and are untrusted, so they can raise risk but never lower it.
|
|
16
|
+
* 4. A tool whose name matches neither a read nor a write verb fails closed: it is treated as a
|
|
17
|
+
* high-risk write path and counted as unclassified until someone declares it.
|
|
18
|
+
*/
|
|
19
|
+
export declare const CLASSIFIER_VERSION = "1.0.0";
|
|
20
|
+
export type RiskTier = "low" | "medium" | "high" | "critical";
|
|
21
|
+
export type Reversibility = "reversible" | "compensable" | "irreversible" | "unknown";
|
|
22
|
+
export type SideEffect = "payment" | "access_grant" | "destructive" | "code_exec" | "deploy" | "outbound_msg" | "external_write" | "data_write";
|
|
23
|
+
export type DataClass = "financial" | "pii" | "secrets" | "prod_data" | "comms";
|
|
24
|
+
export type ProfileSource = "declared" | "registry" | "annotation" | "heuristic" | "unknown";
|
|
25
|
+
/** The MCP tool annotations (spec 2025-03-26+). Hints from the server, never trusted to lower risk. */
|
|
26
|
+
export interface ToolAnnotations {
|
|
27
|
+
title?: string;
|
|
28
|
+
readOnlyHint?: boolean;
|
|
29
|
+
destructiveHint?: boolean;
|
|
30
|
+
idempotentHint?: boolean;
|
|
31
|
+
openWorldHint?: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface ToolDescriptor {
|
|
34
|
+
name: string;
|
|
35
|
+
description?: string | null;
|
|
36
|
+
input_schema?: unknown;
|
|
37
|
+
annotations?: ToolAnnotations | null;
|
|
38
|
+
/** Registry value. Present when the tool was registered against a connection. */
|
|
39
|
+
write_path?: boolean | null;
|
|
40
|
+
}
|
|
41
|
+
/** What a person can pin for a tool. Every field optional; anything set beats the heuristics. */
|
|
42
|
+
export interface DeclaredProfile {
|
|
43
|
+
write_path?: boolean;
|
|
44
|
+
risk_tier?: RiskTier;
|
|
45
|
+
reversibility?: Reversibility;
|
|
46
|
+
data_classes?: DataClass[];
|
|
47
|
+
side_effects?: SideEffect[];
|
|
48
|
+
}
|
|
49
|
+
export interface ToolProfile {
|
|
50
|
+
tool: string;
|
|
51
|
+
write_path: boolean;
|
|
52
|
+
risk_tier: RiskTier;
|
|
53
|
+
reversibility: Reversibility;
|
|
54
|
+
data_classes: DataClass[];
|
|
55
|
+
side_effects: SideEffect[];
|
|
56
|
+
source: ProfileSource;
|
|
57
|
+
/** True when no verb matched and the profile is a fail-closed default. */
|
|
58
|
+
unclassified: boolean;
|
|
59
|
+
/** Human-readable reasons, in the order they were applied. */
|
|
60
|
+
basis: string[];
|
|
61
|
+
classifier_version: string;
|
|
62
|
+
}
|
|
63
|
+
export declare const tierRank: (t: RiskTier) => number;
|
|
64
|
+
export declare const maxTier: (a: RiskTier, b: RiskTier) => RiskTier;
|
|
65
|
+
export declare const atLeast: (t: RiskTier, floor: RiskTier) => boolean;
|
|
66
|
+
/** The verb part of a namespaced or dotted tool name: `github/create_release` → `create_release`. */
|
|
67
|
+
export declare function bareToolName(tool: string): string;
|
|
68
|
+
export declare function classifyTool(t: ToolDescriptor, declared?: DeclaredProfile | null): ToolProfile;
|
|
69
|
+
export type CoverageRung = "guarded" | "exercised" | "governed" | "enforced";
|
|
70
|
+
export interface CoverageItem {
|
|
71
|
+
/** `server/tool` or `connection/tool`. */
|
|
72
|
+
key: string;
|
|
73
|
+
profile: ToolProfile;
|
|
74
|
+
guarded?: boolean;
|
|
75
|
+
exercised?: boolean;
|
|
76
|
+
governed?: boolean;
|
|
77
|
+
enforced?: boolean;
|
|
78
|
+
}
|
|
79
|
+
export interface CoverageRungResult {
|
|
80
|
+
covered: number;
|
|
81
|
+
total: number;
|
|
82
|
+
/** null when there is nothing to cover: no high-risk write paths at all. */
|
|
83
|
+
pct: number | null;
|
|
84
|
+
}
|
|
85
|
+
export interface CoveragePath {
|
|
86
|
+
key: string;
|
|
87
|
+
risk_tier: RiskTier;
|
|
88
|
+
unclassified: boolean;
|
|
89
|
+
guarded?: boolean;
|
|
90
|
+
exercised?: boolean;
|
|
91
|
+
governed?: boolean;
|
|
92
|
+
enforced?: boolean;
|
|
93
|
+
}
|
|
94
|
+
export interface Coverage {
|
|
95
|
+
classifier_version: string;
|
|
96
|
+
threshold: RiskTier;
|
|
97
|
+
/** High-consequence write paths: the denominator for every rung. */
|
|
98
|
+
high_risk_write_paths: number;
|
|
99
|
+
/** How many of those are fail-closed defaults nobody has classified yet. */
|
|
100
|
+
unclassified: number;
|
|
101
|
+
rungs: Partial<Record<CoverageRung, CoverageRungResult>>;
|
|
102
|
+
/** The paths themselves, sorted by key so the list is stable. */
|
|
103
|
+
paths: CoveragePath[];
|
|
104
|
+
}
|
|
105
|
+
export declare function computeCoverage(items: CoverageItem[], threshold?: RiskTier): Coverage;
|
|
106
|
+
/** One line a person can read: "guarded 3/8 (38%) · 2 unclassified". */
|
|
107
|
+
export declare function coverageLine(c: Coverage, rung?: CoverageRung): string;
|