@murk-security/cli 0.0.0-stage → 0.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/mcp-server.mjs ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * MURK MCP tool surface — the single source of truth for the MCP tools, shared by:
3
+ * - the hosted HTTP endpoint (control-plane/src/mcp.ts -> mcp-http.ts), and
4
+ * - the local CLI MCP server (`murk mcp`, in murk.mjs) which bundles the tunnel.
5
+ *
6
+ * It is transport- and auth-agnostic: it takes an `api(method, path, body)` that performs an
7
+ * authenticated control-plane request (absolute path from the host root, e.g. /api/applications)
8
+ * and returns parsed JSON (throwing on a non-2xx). No backend logic lives here; every tool is a
9
+ * call to an existing control-plane endpoint.
10
+ */
11
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
12
+ import { z } from 'zod';
13
+
14
+ const ok = (data) => ({
15
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
16
+ });
17
+ const fail = (error) => ({
18
+ content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
19
+ isError: true,
20
+ });
21
+
22
+ /** Build the MURK MCP server with all tools, backed by the given authenticated `api`. */
23
+ export function buildMurkMcpServer(api, opts = {}) {
24
+ const RUNNER_MODE = opts.runnerMode ?? 'basic-harness';
25
+
26
+ async function getApplication(applicationId) {
27
+ const apps = await api('GET', '/api/applications');
28
+ const list = Array.isArray(apps) ? apps : (apps.applications ?? []);
29
+ const app = list.find((a) => a.id === applicationId);
30
+ if (!app) throw new Error(`application ${applicationId} not found`);
31
+ return app;
32
+ }
33
+
34
+ // Create -> preflight -> launch an assessment of the given kind (the flow the dashboard
35
+ // uses), returning the started run.
36
+ async function launchAssessment(app, o) {
37
+ const config = {
38
+ applicationId: app.id,
39
+ applicationName: app.name,
40
+ assessmentName: o.name,
41
+ target: {
42
+ sourceAssetId: app.sourceAssetId,
43
+ environmentAssetIds: (app.environments ?? []).map((e) => e.assetId),
44
+ },
45
+ scope: {},
46
+ identities: app.identities ?? [],
47
+ policy: { profile: 'standard', destructiveActions: false, maxConcurrency: 2, maxDurationMinutes: 20 },
48
+ packs: ['surface', 'sast', 'intel', 'scan', 'authz'],
49
+ kind: o.kind,
50
+ ...(o.remediateFindingId ? { remediateFindingId: o.remediateFindingId } : {}),
51
+ };
52
+ const created = await api('POST', '/api/assessments', config);
53
+ const pre = await api('POST', `/api/assessments/${created.id}/preflight`, { runnerMode: RUNNER_MODE });
54
+ if (!pre.ok) {
55
+ const failed = (pre.checks ?? []).filter((c) => !c.ok).map((c) => (c.detail ? `${c.name}: ${c.detail}` : c.name));
56
+ throw new Error(`preflight did not pass: ${failed.join('; ') || 'unknown'}`);
57
+ }
58
+ return api('POST', `/api/assessments/${created.id}/runs`, { runnerMode: RUNNER_MODE });
59
+ }
60
+
61
+ const server = new McpServer({ name: 'murk', version: '0.1.0' });
62
+
63
+ server.registerTool(
64
+ 'murk_list_applications',
65
+ { title: 'List applications', description: 'List the applications (targets) in your MURK organization, including locally-connected apps.' },
66
+ async () => {
67
+ try { return ok(await api('GET', '/api/applications')); } catch (e) { return fail(e); }
68
+ },
69
+ );
70
+
71
+ server.registerTool(
72
+ 'murk_list_findings',
73
+ {
74
+ title: 'List findings',
75
+ description: 'List findings, optionally filtered by application, status (open/fixed/regressed/accepted) or severity.',
76
+ inputSchema: { applicationId: z.string().optional(), status: z.string().optional(), severity: z.string().optional() },
77
+ },
78
+ async ({ applicationId, status, severity }) => {
79
+ try {
80
+ const data = await api('GET', '/api/findings');
81
+ let list = Array.isArray(data) ? data : (data.findings ?? []);
82
+ list = list.filter((f) =>
83
+ (!applicationId || f.applicationId === applicationId) &&
84
+ (!status || f.status === status) &&
85
+ (!severity || f.severity === severity));
86
+ return ok(list);
87
+ } catch (e) { return fail(e); }
88
+ },
89
+ );
90
+
91
+ server.registerTool(
92
+ 'murk_get_finding',
93
+ { title: 'Get finding', description: 'Get one finding by id, including its reproduction recipe and any generated patch.', inputSchema: { findingId: z.string() } },
94
+ async ({ findingId }) => {
95
+ try { return ok(await api('GET', `/api/findings/${encodeURIComponent(findingId)}`)); } catch (e) { return fail(e); }
96
+ },
97
+ );
98
+
99
+ server.registerTool(
100
+ 'murk_list_runs',
101
+ { title: 'List runs', description: 'List assessment runs in your organization.' },
102
+ async () => {
103
+ try { return ok(await api('GET', '/api/runs')); } catch (e) { return fail(e); }
104
+ },
105
+ );
106
+
107
+ server.registerTool(
108
+ 'murk_get_run',
109
+ { title: 'Get run', description: 'Get a run and its findings by id.', inputSchema: { runId: z.string() } },
110
+ async ({ runId }) => {
111
+ try {
112
+ // Let a missing/erroring run surface as a clear failure (like murk_get_finding) rather
113
+ // than silently returning {}. Findings are tolerated as [] (a valid run may have none).
114
+ const run = await api('GET', `/api/runs/${encodeURIComponent(runId)}`);
115
+ const findings = await api('GET', `/api/runs/${encodeURIComponent(runId)}/findings`).catch(() => []);
116
+ return ok({ run, findings });
117
+ } catch (e) { return fail(e); }
118
+ },
119
+ );
120
+
121
+ server.registerTool(
122
+ 'murk_scan',
123
+ { title: 'Scan an application', description: 'Start a security assessment of an application (public or locally-connected). Returns the started run.', inputSchema: { applicationId: z.string() } },
124
+ async ({ applicationId }) => {
125
+ try { return ok(await launchAssessment(await getApplication(applicationId), { kind: 'assess', name: 'Scan (via MCP)' })); } catch (e) { return fail(e); }
126
+ },
127
+ );
128
+
129
+ server.registerTool(
130
+ 'murk_retest',
131
+ { title: 'Retest findings', description: 'Re-verify an application’s known findings; findings that no longer reproduce are marked fixed.', inputSchema: { applicationId: z.string() } },
132
+ async ({ applicationId }) => {
133
+ try { return ok(await launchAssessment(await getApplication(applicationId), { kind: 'retest', name: 'Retest (via MCP)' })); } catch (e) { return fail(e); }
134
+ },
135
+ );
136
+
137
+ server.registerTool(
138
+ 'murk_create_patch',
139
+ { title: 'Create a patch', description: 'Generate a code patch for one finding (requires a connected source repo) and open a PR.', inputSchema: { findingId: z.string(), applicationId: z.string() } },
140
+ async ({ findingId, applicationId }) => {
141
+ try {
142
+ return ok(await launchAssessment(await getApplication(applicationId), { kind: 'remediate', name: 'Patch (via MCP)', remediateFindingId: findingId }));
143
+ } catch (e) { return fail(e); }
144
+ },
145
+ );
146
+
147
+ return server;
148
+ }
package/murk.mjs ADDED
@@ -0,0 +1,316 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * murk — run MURK's MCP server for your AI agent and scan locally-running apps with MURK's
4
+ * HOSTED runner over a secure OUTBOUND tunnel. Nothing inbound to your network, no local runner,
5
+ * no model key. You choose exactly which localhost URLs are exposed; this process forwards ONLY
6
+ * those, read-only, and refuses everything else.
7
+ *
8
+ * murk login # browser sign-in; authorizes this machine
9
+ * murk mcp --expose http://localhost:3000 # MCP server for your agent + tunnel
10
+ * murk scan http://localhost:3000 # one-shot: connect, scan, disconnect
11
+ * murk connect --expose http://localhost:3000 # tunnel only (no MCP)
12
+ *
13
+ * Env:
14
+ * MURK_API_URL API origin. Default https://api.murk.sh (set to http://localhost:8788 for dev)
15
+ * MURK_DASHBOARD_URL dashboard origin for login. Default https://app.murk.sh (localhost:3000 for dev)
16
+ * MURK_API_TOKEN an org API token (murk_...) — overrides the stored login credential (for CI)
17
+ */
18
+ import WebSocket from 'ws';
19
+ import http from 'node:http';
20
+ import os from 'node:os';
21
+ import path from 'node:path';
22
+ import { randomBytes } from 'node:crypto';
23
+ import { spawn } from 'node:child_process';
24
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
25
+ import { pathToFileURL } from 'node:url';
26
+
27
+ // Default to the hosted MURK service; devs point these at a local control-plane/dashboard.
28
+ const API_URL = (process.env.MURK_API_URL ?? 'https://api.murk.sh').replace(/\/$/, '');
29
+ const DASHBOARD_URL = (process.env.MURK_DASHBOARD_URL ?? 'https://app.murk.sh').replace(/\/$/, '');
30
+ const MAX_BODY = 10 * 1024 * 1024;
31
+ const CRED_PATH = path.join(os.homedir(), '.murk', 'credentials.json');
32
+
33
+ function die(msg) { process.stderr.write(`murk: ${msg}\n`); process.exit(1); }
34
+ function log(msg) { process.stderr.write(`murk: ${msg}\n`); }
35
+
36
+ // Credential: the MURK_API_TOKEN env (CI) wins; otherwise the token stored by `murk login`.
37
+ function readCredential() {
38
+ try { return JSON.parse(readFileSync(CRED_PATH, 'utf8')).token || ''; } catch { return ''; }
39
+ }
40
+ function saveCredential(token) {
41
+ mkdirSync(path.dirname(CRED_PATH), { recursive: true });
42
+ writeFileSync(CRED_PATH, JSON.stringify({ token }, null, 2), { mode: 0o600 });
43
+ }
44
+ let TOKEN = process.env.MURK_API_TOKEN || readCredential();
45
+
46
+ // Only localhost / private (RFC1918, link-local, CGNAT) targets — your own machine/network.
47
+ export function isLocal(raw) {
48
+ let host;
49
+ try { host = new URL(raw).hostname.toLowerCase(); } catch { return false; }
50
+ if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1); // WHATWG URL keeps IPv6 brackets
51
+ if (host === 'localhost' || host === '::1' || host.endsWith('.localhost') || host.endsWith('.local') || host.endsWith('.internal')) return true;
52
+ const m = /^(\d{1,3})\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/.exec(host);
53
+ if (!m) return false;
54
+ const a = Number(m[1]), b = Number(m[2]);
55
+ return a === 127 || a === 0 || a === 10 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31) || (a === 169 && b === 254);
56
+ }
57
+
58
+ export const normOrigin = (u) => new URL(u).origin;
59
+
60
+ /** Pure scope decision — the client-side trust boundary, extracted so it is unit-testable.
61
+ * `allow` is a Set of exact exposed origins. Returns the resolved URL to fetch, or a refusal
62
+ * reason. Enforces: read-only (GET/HEAD), a parseable path, and exact-origin allowlisting
63
+ * (so off-target host/port/scheme, metadata, and other RFC1918 destinations are all refused).
64
+ * Redirects are NOT followed to another host by the caller (`redirect: 'manual'`). */
65
+ export function scopeDecision(allow, msg) {
66
+ const method = String(msg?.method || 'GET').toUpperCase();
67
+ if (method !== 'GET' && method !== 'HEAD') return { ok: false, message: 'refused: read-only (GET/HEAD) only' };
68
+ let resolved;
69
+ try { resolved = new URL(String(msg?.path ?? '/'), msg?.target); } catch { return { ok: false, message: 'refused: bad path' }; }
70
+ if (!allow.has(resolved.origin)) return { ok: false, message: `refused: ${resolved.origin} is not an exposed target` };
71
+ return { ok: true, method, resolved };
72
+ }
73
+
74
+ async function api(method, path, body) {
75
+ const res = await fetch(`${API_URL}${path}`, {
76
+ method,
77
+ headers: { Authorization: `Bearer ${TOKEN}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
78
+ body: body === undefined ? undefined : JSON.stringify(body),
79
+ });
80
+ const text = await res.text();
81
+ let data; try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; }
82
+ if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
83
+ return data;
84
+ }
85
+
86
+ /** Open the tunnel for a set of exposed origins. `onReady(url, appId)` fires as each target
87
+ * is registered server-side. The scope allowlist is enforced HERE — this process is the
88
+ * trust boundary on the customer side. */
89
+ function openTunnel(exposed, { onReady, onClose } = {}) {
90
+ const allow = new Set(exposed.map(normOrigin));
91
+ const wsUrl = `${API_URL.replace(/^http/, 'ws')}/api/local-scan/tunnel`;
92
+ const ws = new WebSocket(wsUrl, { headers: { Authorization: `Bearer ${TOKEN}` } });
93
+
94
+ ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', targets: exposed.map((url) => ({ url })) })));
95
+ ws.on('unexpected-response', (_req, res) => die(`tunnel rejected (HTTP ${res.statusCode}). Check MURK_API_TOKEN / MURK_API_URL.`));
96
+ ws.on('message', async (raw) => {
97
+ let msg; try { msg = JSON.parse(raw.toString()); } catch { return; }
98
+ if (msg.t === 'ready') { log(`exposed ${msg.url} -> application ${msg.applicationId}`); onReady?.(msg.url, msg.applicationId); return; }
99
+ if (msg.t === 'rejected') { log(`server rejected ${msg.url}: ${msg.reason}`); return; }
100
+ if (msg.t !== 'req') return;
101
+ // ---- CLIENT-SIDE SCOPE ENFORCEMENT (the trust boundary) ----
102
+ const reply = (obj) => { try { ws.send(JSON.stringify({ ...obj, id: msg.id })); } catch { /* closing */ } };
103
+ const decision = scopeDecision(allow, msg);
104
+ if (!decision.ok) return reply({ t: 'err', message: decision.message });
105
+ const { method, resolved } = decision;
106
+ try {
107
+ const headers = (msg.headers && typeof msg.headers === 'object') ? { ...msg.headers } : {};
108
+ delete headers.host; delete headers.Host;
109
+ const r = await fetch(resolved, { method, headers, redirect: 'manual' }); // never follow a redirect to another host
110
+ const buf = Buffer.from(await r.arrayBuffer());
111
+ const body = buf.length > MAX_BODY ? buf.subarray(0, MAX_BODY) : buf;
112
+ const outHeaders = {};
113
+ r.headers.forEach((v, k) => { outHeaders[k] = v; });
114
+ reply({ t: 'res', status: r.status, headers: outHeaders, bodyB64: body.toString('base64') });
115
+ } catch (e) {
116
+ reply({ t: 'err', message: `local fetch failed: ${e instanceof Error ? e.message : 'error'}` });
117
+ }
118
+ });
119
+ ws.on('close', () => onClose?.());
120
+ ws.on('error', (e) => log(`tunnel error: ${e instanceof Error ? e.message : e}`));
121
+ return ws;
122
+ }
123
+
124
+ function preflightChecks(exposed) {
125
+ if (!exposed.length) die('nothing to expose. Use: murk mcp --expose http://localhost:3000');
126
+ for (const u of exposed) {
127
+ try { new URL(u); } catch { die(`not a URL: ${u}`); }
128
+ if (!isLocal(u)) die(`${u} is not a localhost/private origin. murk only tunnels your own local/internal apps.`);
129
+ }
130
+ }
131
+
132
+ async function cmdConnect(exposed) {
133
+ await ensureAuth();
134
+ preflightChecks(exposed);
135
+ log(`connecting to ${API_URL} ...`);
136
+ let ws;
137
+ const connect = () => {
138
+ ws = openTunnel(exposed, {
139
+ onClose: () => { log('tunnel closed; reconnecting in 3s (Ctrl-C to stop) ...'); setTimeout(connect, 3000); },
140
+ });
141
+ };
142
+ connect();
143
+ const stop = () => { log('disconnecting.'); try { ws?.close(); } catch { /* noop */ } process.exit(0); };
144
+ process.on('SIGINT', stop);
145
+ process.on('SIGTERM', stop);
146
+ }
147
+
148
+ async function launchScan(applicationId) {
149
+ const apps = await api('GET', '/api/applications');
150
+ const list = Array.isArray(apps) ? apps : (apps.applications ?? []);
151
+ const app = list.find((a) => a.id === applicationId);
152
+ if (!app) throw new Error('application not found after connect');
153
+ const assetIds = (app.environments ?? []).map((e) => e.assetId);
154
+ const config = {
155
+ applicationId: app.id, applicationName: app.name, assessmentName: `Local scan ${new Date().toISOString()}`,
156
+ target: { environmentAssetIds: assetIds }, scope: {}, identities: app.identities ?? [],
157
+ policy: { profile: 'standard', destructiveActions: false, maxConcurrency: 1, maxDurationMinutes: 15 },
158
+ packs: ['surface', 'sast', 'intel', 'scan', 'authz'], kind: 'assess',
159
+ };
160
+ const created = await api('POST', '/api/assessments', config);
161
+ const pre = await api('POST', `/api/assessments/${created.id}/preflight`, { runnerMode: 'basic-harness' });
162
+ if (pre && pre.ok === false) throw new Error('preflight did not pass');
163
+ const run = await api('POST', `/api/assessments/${created.id}/runs`, { runnerMode: 'basic-harness' });
164
+ return run.runId ?? run.id ?? run.run?.id;
165
+ }
166
+
167
+ async function cmdScan(url) {
168
+ await ensureAuth();
169
+ preflightChecks([url]);
170
+ let done = false;
171
+ const ws = openTunnel([url], {
172
+ onReady: async (_u, applicationId) => {
173
+ try {
174
+ log('starting hosted scan ...');
175
+ const runId = await launchScan(applicationId);
176
+ log(`run ${runId} started; watching ...`);
177
+ for (;;) {
178
+ await new Promise((r) => setTimeout(r, 4000));
179
+ const run = await api('GET', `/api/runs/${encodeURIComponent(runId)}`).catch(() => ({}));
180
+ const status = String(run.status ?? run.run?.status ?? '').toLowerCase();
181
+ if (['completed', 'failed', 'cancelled', 'error'].includes(status)) {
182
+ log(`scan ${status}. View it in the dashboard (application ${applicationId}).`);
183
+ break;
184
+ }
185
+ }
186
+ } catch (e) { log(`scan failed: ${e instanceof Error ? e.message : e}`); }
187
+ finally { done = true; try { ws.close(); } catch { /* noop */ } process.exit(0); }
188
+ },
189
+ onClose: () => { if (!done) die('tunnel closed before the scan finished.'); },
190
+ });
191
+ }
192
+
193
+ // `murk mcp [--expose URL ...]` — run MURK's MCP server over stdio (for `claude mcp add`,
194
+ // Cursor, etc.) AND, in the same process, bridge any --expose'd local apps over the tunnel so
195
+ // the hosted runner can scan them. One command: the tunnel is just part of the MCP server. The
196
+ // --expose set is the user-declared allowlist, enforced client-side in openTunnel.
197
+ async function cmdMcp(exposed) {
198
+ await ensureAuth();
199
+ for (const u of exposed) {
200
+ try { new URL(u); } catch { die(`not a URL: ${u}`); }
201
+ if (!isLocal(u)) die(`${u} is not a localhost/private origin. murk only exposes your own local/internal apps.`);
202
+ }
203
+ if (exposed.length) {
204
+ log(`exposing ${exposed.join(', ')} over the tunnel`);
205
+ let ws;
206
+ const connect = () => {
207
+ ws = openTunnel(exposed, {
208
+ onClose: () => { log('tunnel closed; reconnecting in 3s ...'); setTimeout(connect, 3000); },
209
+ });
210
+ };
211
+ connect();
212
+ }
213
+ // stdout is the MCP protocol channel; keep all logging on stderr (log()/die() already do).
214
+ const { buildMurkMcpServer } = await import('./mcp-server.mjs');
215
+ const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
216
+ const server = buildMurkMcpServer(api);
217
+ await server.connect(new StdioServerTransport());
218
+ log(`murk MCP server ready on stdio${exposed.length ? ` (exposing ${exposed.length} local app(s))` : ''}`);
219
+ }
220
+
221
+ function openBrowser(url) {
222
+ if (process.env.MURK_NO_BROWSER) return; // headless/SSH: the URL is printed to stderr instead
223
+ const [cmd, args] = process.platform === 'darwin'
224
+ ? ['open', [url]]
225
+ : process.platform === 'win32'
226
+ ? ['cmd', ['/c', 'start', '', url]]
227
+ : ['xdg-open', [url]];
228
+ try { spawn(cmd, args, { stdio: 'ignore', detached: true }).unref(); } catch { /* fall back to the printed URL */ }
229
+ }
230
+
231
+ const LOGIN_OK_HTML =
232
+ '<!doctype html><meta charset=utf-8><title>MURK CLI authorized</title>' +
233
+ '<body style="font:16px system-ui;max-width:32rem;margin:4rem auto;padding:0 1rem;color:#111">' +
234
+ '<h1 style="font-size:1.25rem">✓ MURK CLI authorized</h1>' +
235
+ '<p>You can close this tab and return to your terminal.</p></body>';
236
+
237
+ // Browser sign-in that authorizes THIS machine (like `gh`/`wrangler login`): open the dashboard
238
+ // /activate page, which signs you in and approves the CLI; it then hands a one-time code back to
239
+ // this process over loopback, which we exchange for an org token stored in ~/.murk/credentials.
240
+ async function cmdLogin() {
241
+ const state = randomBytes(24).toString('base64url');
242
+ const token = await new Promise((resolve, reject) => {
243
+ let timer;
244
+ const server = http.createServer((req, res) => {
245
+ let u; try { u = new URL(req.url, 'http://127.0.0.1'); } catch { res.writeHead(400).end(); return; }
246
+ const code = u.searchParams.get('code');
247
+ if (!code || u.searchParams.get('state') !== state) { res.writeHead(400, { 'Content-Type': 'text/html' }).end('<h1>Invalid login response</h1>'); return; }
248
+ res.writeHead(200, { 'Content-Type': 'text/html' }).end(LOGIN_OK_HTML);
249
+ clearTimeout(timer);
250
+ server.close();
251
+ fetch(`${API_URL}/api/cli/auth/exchange`, {
252
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, state }),
253
+ }).then(async (r) => {
254
+ const d = await r.json().catch(() => ({}));
255
+ if (!r.ok || !d.token) throw new Error(d.error || `exchange failed (HTTP ${r.status})`);
256
+ resolve(d.token);
257
+ }).catch(reject);
258
+ });
259
+ server.on('error', reject);
260
+ server.listen(0, '127.0.0.1', () => {
261
+ const { port } = server.address();
262
+ const url = `${DASHBOARD_URL}/?cli_login=1&port=${port}&state=${encodeURIComponent(state)}`;
263
+ log('opening your browser to sign in and authorize this machine:');
264
+ log(` ${url}`);
265
+ openBrowser(url);
266
+ log('waiting for approval (Ctrl-C to cancel) ...');
267
+ });
268
+ timer = setTimeout(() => { try { server.close(); } catch { /* noop */ } reject(new Error('login timed out after 3 minutes')); }, 180_000);
269
+ timer.unref(); // don't keep the process alive once login completes
270
+ });
271
+ saveCredential(token);
272
+ TOKEN = token;
273
+ log('logged in. Credential saved to ~/.murk/credentials.json');
274
+ }
275
+
276
+ // Ensure we have a credential before doing authenticated work; log in via the browser if not.
277
+ async function ensureAuth() {
278
+ if (TOKEN) return;
279
+ log('not logged in — starting browser sign-in ...');
280
+ await cmdLogin();
281
+ }
282
+
283
+ function parseExposed(rest) {
284
+ const exposed = [];
285
+ for (let i = 0; i < rest.length; i++) {
286
+ if (rest[i] === '--expose' || rest[i] === '-e') { if (rest[i + 1]) exposed.push(rest[++i]); }
287
+ else if (rest[i].startsWith('http')) exposed.push(rest[i]);
288
+ }
289
+ return exposed;
290
+ }
291
+
292
+ function main() {
293
+ const [cmd, ...rest] = process.argv.slice(2);
294
+ const fatal = (e) => die(e instanceof Error ? e.message : String(e));
295
+ if (cmd === 'login') {
296
+ void cmdLogin().catch(fatal);
297
+ } else if (cmd === 'mcp') {
298
+ void cmdMcp(parseExposed(rest)).catch(fatal);
299
+ } else if (cmd === 'connect') {
300
+ void cmdConnect(parseExposed(rest)).catch(fatal);
301
+ } else if (cmd === 'scan') {
302
+ void cmdScan(rest.find((a) => a.startsWith('http'))).catch(fatal);
303
+ } else {
304
+ process.stdout.write(
305
+ 'usage:\n' +
306
+ ' murk login # browser sign-in; authorizes this machine\n' +
307
+ ' murk mcp [--expose http://localhost:3000 ...] # MCP server for your agent; exposes local apps\n' +
308
+ ' murk scan http://localhost:3000 # one-shot: connect, scan, disconnect\n' +
309
+ ' murk connect --expose http://localhost:3000 # tunnel only (no MCP)\n',
310
+ );
311
+ process.exit(cmd ? 1 : 0);
312
+ }
313
+ }
314
+
315
+ // Run the CLI only when executed directly, not when imported (e.g. by the test suite).
316
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
package/package.json CHANGED
@@ -1,6 +1,28 @@
1
1
  {
2
2
  "name": "@murk-security/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "description": "Run MURK's MCP server for your AI agent and scan locally-running apps with MURK's hosted runner over a secure outbound tunnel — no local runner, no model key.",
5
+ "type": "module",
6
+ "bin": {
7
+ "murk": "./murk.mjs"
8
+ },
9
+ "files": [
10
+ "murk.mjs",
11
+ "mcp-server.mjs"
12
+ ],
13
+ "keywords": [
14
+ "murk",
15
+ "mcp",
16
+ "security",
17
+ "scanner",
18
+ "model-context-protocol"
19
+ ],
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "dependencies": {
24
+ "@modelcontextprotocol/sdk": "^1.30.0",
25
+ "ws": "^8.21.0",
26
+ "zod": "^4.5.4"
27
+ }
28
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
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.