@murk-security/cli 0.3.2 → 0.5.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/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # @murk-security/cli
2
+
3
+ Run [MURK](https://murk.sh)'s MCP server for your AI agent, and scan your
4
+ locally-running apps with MURK's **hosted** runner over a secure **outbound** tunnel.
5
+
6
+ - **No local runner, no model key** — scanning happens on MURK's infrastructure.
7
+ - **Nothing inbound** to your network. You choose exactly which `localhost` URLs are
8
+ exposed; this process forwards **only** those, **read-only** (GET/HEAD), and refuses
9
+ everything else. The allowlist is enforced on your machine — it's the trust boundary.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ npm i -g @murk-security/cli
15
+ # or run without installing:
16
+ npx @murk-security/cli --help
17
+ ```
18
+
19
+ Requires Node ≥ 20.
20
+
21
+ ## Quickstart
22
+
23
+ ```sh
24
+ murk login # browser sign-in; authorizes this machine
25
+ murk add murk-security/northwind-store # add an app from a connected GitHub repo
26
+ murk apps # list your applications + ids
27
+ murk scan http://localhost:3000 # scan a locally-running app (full depth)
28
+ ```
29
+
30
+ ## Use it from an AI agent (MCP)
31
+
32
+ **Hosted** (recommended) — no local process to keep running:
33
+
34
+ ```sh
35
+ claude mcp add --transport http murk https://api.murk.sh/mcp
36
+ ```
37
+
38
+ **Local** — also bridges any `--expose`'d local apps so the hosted runner can reach them:
39
+
40
+ ```sh
41
+ murk mcp --expose http://localhost:3000
42
+ ```
43
+
44
+ Either way the agent gets these tools:
45
+
46
+ | Tool | What it does |
47
+ | --- | --- |
48
+ | `murk_list_applications` | List your apps (targets). |
49
+ | `murk_add_application` | Add an app from a connected GitHub repo. |
50
+ | `murk_scan` | Start a scan. Accepts an app **id or name**; `scanProfile: pr\|full`. |
51
+ | `murk_retest` | Re-verify known findings; fixed ones are closed. |
52
+ | `murk_create_patch` | Generate a code patch for a finding and open a PR. |
53
+ | `murk_list_findings` | List findings, filtered by app / status / severity. |
54
+ | `murk_get_finding` | One finding, with its reproduction recipe and any patch. |
55
+ | `murk_get_run` | A run and its findings — poll this for scan progress. |
56
+
57
+ Scans and patches run **asynchronously** — the start tools return a run id; poll
58
+ `murk_get_run` / `murk_get_finding` for results.
59
+
60
+ ## Commands
61
+
62
+ ```
63
+ murk login browser sign-in; authorizes this machine
64
+ murk logout revoke this machine's credential
65
+ murk whoami show login status
66
+ murk apps list your applications (id, name, source)
67
+ murk add <owner/repo> [--name NAME] add an app from a connected GitHub repo
68
+ murk scan <http://localhost:PORT> [--pr|--full] one-shot: connect, scan, disconnect
69
+ murk mcp [--expose <http://localhost:PORT> ...] run the MCP server for your agent
70
+ murk connect --expose <http://localhost:PORT> open the tunnel only (no MCP)
71
+ ```
72
+
73
+ ### Scan depth
74
+
75
+ - `--full` — deep whole-repo audit with the model council (~45 min). **Default.**
76
+ - `--pr` — fast, cheaper scan, no council (~10 min).
77
+
78
+ ## GitHub apps
79
+
80
+ `murk add owner/repo` requires the **MURK GitHub App** to be installed on that repository
81
+ (and granted access). Connect it from the MURK dashboard, then the repo becomes available
82
+ to `murk add` and `murk_add_application`.
83
+
84
+ ## Environment
85
+
86
+ | Variable | Default | Purpose |
87
+ | --- | --- | --- |
88
+ | `MURK_API_URL` | `https://api.murk.sh` | API origin (set to `http://localhost:8788` for dev). |
89
+ | `MURK_DASHBOARD_URL` | `https://app.murk.sh` | Dashboard origin for login. |
90
+ | `MURK_API_TOKEN` | — | Org API token (`murk_…`) for CI; overrides the stored login. |
91
+ | `MURK_NO_BROWSER` | — | Set to print the login URL instead of opening a browser (headless/SSH). |
92
+
93
+ The login credential is stored at `~/.murk/credentials.json` (mode `600`).
package/mcp-server.mjs CHANGED
@@ -14,22 +14,71 @@ import { z } from 'zod';
14
14
  const ok = (data) => ({
15
15
  content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
16
16
  });
17
- const fail = (error) => ({
18
- content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
19
- isError: true,
20
- });
17
+ const fail = (error) => {
18
+ // Free-pentest gate (402): give the agent a clear, actionable message + link so
19
+ // it tells the user to subscribe rather than reporting a generic error.
20
+ if (error && error.upgrade) {
21
+ const url = error.upgradeUrl || 'the MURK billing page in the dashboard';
22
+ return {
23
+ content: [{
24
+ type: 'text',
25
+ text: `This MURK organization has used its one free pentest. To run more pentests the user needs to subscribe. Tell the user to open ${url} and choose a plan, then retry.`,
26
+ }],
27
+ isError: true,
28
+ };
29
+ }
30
+ return {
31
+ content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
32
+ isError: true,
33
+ };
34
+ };
35
+
36
+ /** Resolve an application by id, or by exact name (case-insensitive), or a unique name
37
+ * substring — so callers can pass a friendly name instead of an opaque id. Exported so the
38
+ * CLI and the MCP tools share one resolution rule. Throws a clear, actionable error. */
39
+ export async function resolveApplication(api, idOrName) {
40
+ const want = String(idOrName ?? '').trim();
41
+ if (!want) throw new Error('no application id or name given');
42
+ const apps = await api('GET', '/api/applications');
43
+ const list = Array.isArray(apps) ? apps : (apps.applications ?? []);
44
+ let app = list.find((a) => a.id === want)
45
+ || list.find((a) => String(a.name).toLowerCase() === want.toLowerCase());
46
+ if (!app) {
47
+ const matches = list.filter((a) => String(a.name).toLowerCase().includes(want.toLowerCase()));
48
+ if (matches.length === 1) [app] = matches;
49
+ else if (matches.length > 1) {
50
+ throw new Error(`"${idOrName}" matches ${matches.length} applications (${matches.map((m) => m.name).join(', ')}); use the exact name or id.`);
51
+ }
52
+ }
53
+ if (!app) throw new Error(`no application matches "${idOrName}". List them with murk_list_applications (or \`murk apps\`).`);
54
+ return app;
55
+ }
56
+
57
+ /** Add an application from a connected GitHub repo (owner/name, grey-box source form).
58
+ * Exported so the CLI's `murk add` and the MCP `murk_add_application` tool share one path. */
59
+ export async function addGithubApplication(api, { githubRepo, name }) {
60
+ const want = String(githubRepo).trim().toLowerCase();
61
+ const conns = await api('GET', '/api/connections');
62
+ const active = (Array.isArray(conns) ? conns : conns.connections ?? []).filter((c) => c.status === 'active');
63
+ let match;
64
+ for (const c of active) {
65
+ const repos = await api('GET', `/api/connections/${encodeURIComponent(c.id)}/repositories`).catch(() => []);
66
+ const r = (Array.isArray(repos) ? repos : repos.repositories ?? []).find((x) => String(x.fullName).toLowerCase() === want);
67
+ if (r) { match = { connectionId: c.id, repositoryId: r.id, fullName: r.fullName }; break; }
68
+ }
69
+ if (!match) throw new Error(`No connected GitHub repository matches "${githubRepo}". Install the MURK GitHub app on that repo (and grant it access), then retry.`);
70
+ const asset = await api('POST', '/api/assets/github', { connectionId: match.connectionId, repositoryId: match.repositoryId });
71
+ return api('POST', '/api/applications', {
72
+ name: name || match.fullName, description: '', context: '',
73
+ sourceAssetId: asset.id, environments: [], identities: [],
74
+ });
75
+ }
21
76
 
22
77
  /** Build the MURK MCP server with all tools, backed by the given authenticated `api`. */
23
78
  export function buildMurkMcpServer(api, opts = {}) {
24
79
  const RUNNER_MODE = opts.runnerMode ?? 'basic-harness';
25
80
 
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
- }
81
+ const getApplication = (idOrName) => resolveApplication(api, idOrName);
33
82
 
34
83
  // Create -> preflight -> launch an assessment of the given kind (the flow the dashboard
35
84
  // uses), returning the started run.
@@ -47,6 +96,7 @@ export function buildMurkMcpServer(api, opts = {}) {
47
96
  policy: { profile: 'standard', destructiveActions: false, maxConcurrency: 2, maxDurationMinutes: 20 },
48
97
  packs: ['surface', 'sast', 'intel', 'scan', 'authz'],
49
98
  kind: o.kind,
99
+ ...(o.scanProfile ? { scanProfile: o.scanProfile } : {}),
50
100
  ...(o.remediateFindingId ? { remediateFindingId: o.remediateFindingId } : {}),
51
101
  };
52
102
  const created = await api('POST', '/api/assessments', config);
@@ -68,11 +118,23 @@ export function buildMurkMcpServer(api, opts = {}) {
68
118
  },
69
119
  );
70
120
 
121
+ server.registerTool(
122
+ 'murk_add_application',
123
+ {
124
+ title: 'Add an application',
125
+ description: 'Add an application from a connected GitHub repository (source-only, grey-box — the form factor for PR scans and patches). The MURK GitHub App must already be installed on the repo. Returns the created application.',
126
+ inputSchema: { githubRepo: z.string().describe('owner/name, e.g. murk-security/northwind-store'), name: z.string().optional() },
127
+ },
128
+ async ({ githubRepo, name }) => {
129
+ try { return ok(await addGithubApplication(api, { githubRepo, name })); } catch (e) { return fail(e); }
130
+ },
131
+ );
132
+
71
133
  server.registerTool(
72
134
  'murk_list_findings',
73
135
  {
74
136
  title: 'List findings',
75
- description: 'List findings, optionally filtered by application, status (open/fixed/regressed/accepted) or severity.',
137
+ description: 'List findings, optionally filtered by application, status (open/in_progress/ready_for_retest/fixed/regressed/accepted) or severity.',
76
138
  inputSchema: { applicationId: z.string().optional(), status: z.string().optional(), severity: z.string().optional() },
77
139
  },
78
140
  async ({ applicationId, status, severity }) => {
@@ -120,15 +182,32 @@ export function buildMurkMcpServer(api, opts = {}) {
120
182
 
121
183
  server.registerTool(
122
184
  '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); }
185
+ {
186
+ title: 'Scan an application',
187
+ description: 'Start a security assessment of an application (public or locally-connected). Returns the started run; the scan runs asynchronously — poll murk_get_run with the returned run id for status and findings.',
188
+ inputSchema: {
189
+ applicationId: z.string().describe('Application id OR name (e.g. "northwind-store"); names are resolved for you.'),
190
+ scanProfile: z.enum(['pr', 'full']).optional().describe(
191
+ "Scan depth. 'full' (default): deep whole-repo audit with the model council (~45 min). " +
192
+ "'pr': fast, cheaper scan with no council (~10 min) — scoped to a pull-request diff when one " +
193
+ 'is available, otherwise a light whole-repo pass.',
194
+ ),
195
+ },
196
+ },
197
+ async ({ applicationId, scanProfile }) => {
198
+ try {
199
+ return ok(await launchAssessment(await getApplication(applicationId), {
200
+ kind: 'assess',
201
+ name: `Scan (via MCP${scanProfile ? `, ${scanProfile}` : ''})`,
202
+ scanProfile,
203
+ }));
204
+ } catch (e) { return fail(e); }
126
205
  },
127
206
  );
128
207
 
129
208
  server.registerTool(
130
209
  '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() } },
210
+ { title: 'Retest findings', description: 'Re-verify an application’s known findings; findings that no longer reproduce are marked fixed.', inputSchema: { applicationId: z.string().describe('Application id OR name.') } },
132
211
  async ({ applicationId }) => {
133
212
  try { return ok(await launchAssessment(await getApplication(applicationId), { kind: 'retest', name: 'Retest (via MCP)' })); } catch (e) { return fail(e); }
134
213
  },
@@ -136,7 +215,7 @@ export function buildMurkMcpServer(api, opts = {}) {
136
215
 
137
216
  server.registerTool(
138
217
  '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() } },
218
+ { title: 'Create a patch', description: 'Generate a code patch for one finding (requires a connected source repo) and open a PR. The patch is generated asynchronously — poll murk_get_finding to see the diff and PR link when ready.', inputSchema: { findingId: z.string(), applicationId: z.string().describe('Application id OR name.') } },
140
219
  async ({ findingId, applicationId }) => {
141
220
  try {
142
221
  return ok(await launchAssessment(await getApplication(applicationId), { kind: 'remediate', name: 'Patch (via MCP)', remediateFindingId: findingId }));
package/murk.mjs CHANGED
@@ -6,8 +6,12 @@
6
6
  * those, read-only, and refuses everything else.
7
7
  *
8
8
  * murk login # browser sign-in; authorizes this machine
9
+ * murk logout # revoke this machine's credential
10
+ * murk whoami # show login status
11
+ * murk apps # list your applications
12
+ * murk add owner/repo [--name NAME] # add an app from a connected GitHub repo
9
13
  * murk mcp --expose http://localhost:3000 # MCP server for your agent + tunnel
10
- * murk scan http://localhost:3000 # one-shot: connect, scan, disconnect
14
+ * murk scan http://localhost:3000 [--pr|--full] # one-shot: connect, scan, disconnect
11
15
  * murk connect --expose http://localhost:3000 # tunnel only (no MCP)
12
16
  *
13
17
  * Env:
@@ -21,7 +25,7 @@ import os from 'node:os';
21
25
  import path from 'node:path';
22
26
  import { randomBytes } from 'node:crypto';
23
27
  import { spawn } from 'node:child_process';
24
- import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
28
+ import { mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
25
29
  import { fileURLToPath } from 'node:url';
26
30
 
27
31
  // Default to the hosted MURK service; devs point these at a local control-plane/dashboard.
@@ -79,10 +83,36 @@ async function api(method, path, body) {
79
83
  });
80
84
  const text = await res.text();
81
85
  let data; try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; }
82
- if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
86
+ if (!res.ok) {
87
+ const err = new Error(data.error ?? `HTTP ${res.status}`);
88
+ err.status = res.status;
89
+ err.upgrade = Boolean(data.upgrade); // free-pentest gate (402)
90
+ err.upgradeUrl = data.upgradeUrl;
91
+ throw err;
92
+ }
83
93
  return data;
84
94
  }
85
95
 
96
+ /** Free-tier gate, surfaced early and clearly. Returns before scanning when the
97
+ * org has spent its free pentest; notes the free run on the first scan. Billing
98
+ * being off (self-host) or unreachable never blocks a scan — the launch stays
99
+ * the authoritative gate. */
100
+ async function ensureCanLaunch() {
101
+ let s;
102
+ try { s = await api('GET', '/api/billing/status'); }
103
+ catch (e) { if (e.status === 503 || e.status === 409) return; throw e; }
104
+ const ent = s.entitlement;
105
+ if (!ent || !ent.gated) return;
106
+ if (!ent.canLaunch) {
107
+ log('you’ve used your free pentest. Subscribe to run more:');
108
+ log(` ${s.billingUrl || API_URL}`);
109
+ process.exit(2);
110
+ }
111
+ if (!ent.subscribed && !ent.freePentestUsed) {
112
+ log(`free trial: this is your 1 free pentest (up to ${ent.freeMaxMinutes} min). Subscribe any time for unlimited: ${s.billingUrl || ''}`.trimEnd());
113
+ }
114
+ }
115
+
86
116
  /** Open the tunnel for a set of exposed origins. `onReady(url, appId)` fires as each target
87
117
  * is registered server-side. The scope allowlist is enforced HERE — this process is the
88
118
  * trust boundary on the customer side. */
@@ -145,7 +175,7 @@ async function cmdConnect(exposed) {
145
175
  process.on('SIGTERM', stop);
146
176
  }
147
177
 
148
- async function launchScan(applicationId) {
178
+ async function launchScan(applicationId, scanProfile) {
149
179
  const apps = await api('GET', '/api/applications');
150
180
  const list = Array.isArray(apps) ? apps : (apps.applications ?? []);
151
181
  const app = list.find((a) => a.id === applicationId);
@@ -156,6 +186,7 @@ async function launchScan(applicationId) {
156
186
  target: { environmentAssetIds: assetIds }, scope: {}, identities: app.identities ?? [],
157
187
  policy: { profile: 'standard', destructiveActions: false, maxConcurrency: 1, maxDurationMinutes: 15 },
158
188
  packs: ['surface', 'sast', 'intel', 'scan', 'authz'], kind: 'assess',
189
+ ...(scanProfile ? { scanProfile } : {}),
159
190
  };
160
191
  const created = await api('POST', '/api/assessments', config);
161
192
  const pre = await api('POST', `/api/assessments/${created.id}/preflight`, { runnerMode: 'basic-harness' });
@@ -164,15 +195,16 @@ async function launchScan(applicationId) {
164
195
  return run.runId ?? run.id ?? run.run?.id;
165
196
  }
166
197
 
167
- async function cmdScan(url) {
198
+ async function cmdScan(url, scanProfile) {
168
199
  await ensureAuth();
169
200
  preflightChecks([url]);
201
+ await ensureCanLaunch(); // fail fast + note the free trial before opening the tunnel
170
202
  let done = false;
171
203
  const ws = openTunnel([url], {
172
204
  onReady: async (_u, applicationId) => {
173
205
  try {
174
- log('starting hosted scan ...');
175
- const runId = await launchScan(applicationId);
206
+ log(`starting hosted ${scanProfile === 'pr' ? 'PR (fast)' : 'full'} scan ...`);
207
+ const runId = await launchScan(applicationId, scanProfile);
176
208
  log(`run ${runId} started; watching ...`);
177
209
  for (;;) {
178
210
  await new Promise((r) => setTimeout(r, 4000));
@@ -183,7 +215,14 @@ async function cmdScan(url) {
183
215
  break;
184
216
  }
185
217
  }
186
- } catch (e) { log(`scan failed: ${e instanceof Error ? e.message : e}`); }
218
+ } catch (e) {
219
+ if (e && e.upgrade) {
220
+ log('you’ve used your free pentest. Subscribe to run more:');
221
+ log(` ${e.upgradeUrl || API_URL}`);
222
+ } else {
223
+ log(`scan failed: ${e instanceof Error ? e.message : e}`);
224
+ }
225
+ }
187
226
  finally { done = true; try { ws.close(); } catch { /* noop */ } process.exit(0); }
188
227
  },
189
228
  onClose: () => { if (!done) die('tunnel closed before the scan finished.'); },
@@ -280,6 +319,73 @@ async function ensureAuth() {
280
319
  await cmdLogin();
281
320
  }
282
321
 
322
+ // Revoke the stored credential server-side and remove it locally. Env-provided tokens
323
+ // (MURK_API_TOKEN, for CI) are left alone — unset the variable to "log out" of those.
324
+ async function cmdLogout() {
325
+ if (process.env.MURK_API_TOKEN) {
326
+ log('MURK_API_TOKEN is set in your environment — unset it to log out. (No stored credential was changed.)');
327
+ return;
328
+ }
329
+ const stored = readCredential();
330
+ if (!stored) { log('not logged in.'); return; }
331
+ try {
332
+ await fetch(`${API_URL}/api/cli/auth/logout`, { method: 'POST', headers: { Authorization: `Bearer ${stored}` } });
333
+ } catch { /* offline — still clear the local credential below */ }
334
+ try { rmSync(CRED_PATH, { force: true }); } catch { /* noop */ }
335
+ TOKEN = '';
336
+ log('logged out — credential revoked and removed.');
337
+ }
338
+
339
+ // Report whether this machine is authenticated, and validate the credential against the API.
340
+ async function cmdWhoami() {
341
+ if (!TOKEN) { log('not logged in. Run: murk login'); return; }
342
+ const src = process.env.MURK_API_TOKEN ? 'MURK_API_TOKEN env' : '~/.murk/credentials.json';
343
+ try {
344
+ const res = await fetch(`${API_URL}/api/applications`, { headers: { Authorization: `Bearer ${TOKEN}` } });
345
+ if (res.ok) log(`logged in to ${API_URL} (credential: ${src})`);
346
+ else if (res.status === 401) log('stored credential is invalid or revoked — run: murk login');
347
+ else log(`logged in (${src}), but ${API_URL} returned HTTP ${res.status}`);
348
+ } catch (e) { log(`could not reach ${API_URL}: ${e instanceof Error ? e.message : e}`); }
349
+ }
350
+
351
+ // Add a GitHub-connected application from the CLI (mirrors the MCP murk_add_application
352
+ // tool; shares addGithubApplication so the two never diverge). Logs go to stderr; the new
353
+ // application id is printed to stdout so it can be piped into scripts.
354
+ async function cmdAdd(repo, name) {
355
+ await ensureAuth();
356
+ if (!repo) die('usage: murk add <owner/repo> [--name NAME]');
357
+ const { addGithubApplication } = await import('./mcp-server.mjs');
358
+ const app = await addGithubApplication(api, { githubRepo: repo, name });
359
+ log(`added application "${app.name}" (${app.id})`);
360
+ process.stdout.write(`${app.id}\n`);
361
+ }
362
+
363
+ // List the organization's applications (id, name, source kind) for discovery.
364
+ async function cmdApps() {
365
+ await ensureAuth();
366
+ const apps = await api('GET', '/api/applications');
367
+ const list = Array.isArray(apps) ? apps : (apps.applications ?? []);
368
+ if (!list.length) { log('no applications yet. Add one: murk add <owner/repo>'); return; }
369
+ for (const a of list) {
370
+ const src = a.sourceAssetId ? 'github' : ((a.environments ?? []).length ? 'env' : '-');
371
+ process.stdout.write(`${a.id} ${a.name} [${src}]\n`);
372
+ }
373
+ }
374
+
375
+ // First positional arg that isn't a flag or a flag's value (for `murk add <repo>`).
376
+ function firstPositional(rest, valueFlags = ['--name']) {
377
+ for (let i = 0; i < rest.length; i++) {
378
+ if (valueFlags.includes(rest[i])) { i++; continue; }
379
+ if (!rest[i].startsWith('-')) return rest[i];
380
+ }
381
+ return undefined;
382
+ }
383
+
384
+ function parseFlagValue(rest, flag) {
385
+ const i = rest.indexOf(flag);
386
+ return i >= 0 && rest[i + 1] ? rest[i + 1] : undefined;
387
+ }
388
+
283
389
  function parseExposed(rest) {
284
390
  const exposed = [];
285
391
  for (let i = 0; i < rest.length; i++) {
@@ -289,26 +395,67 @@ function parseExposed(rest) {
289
395
  return exposed;
290
396
  }
291
397
 
398
+ // Scan depth from flags: --pr / --full, or --profile pr|full. Default (undefined) => 'full'
399
+ // server-side. 'pr' is the fast, cheaper, no-council scan.
400
+ function parseScanProfile(rest) {
401
+ for (let i = 0; i < rest.length; i++) {
402
+ if (rest[i] === '--pr') return 'pr';
403
+ if (rest[i] === '--full') return 'full';
404
+ if (rest[i] === '--profile' && (rest[i + 1] === 'pr' || rest[i + 1] === 'full')) return rest[i + 1];
405
+ }
406
+ return undefined;
407
+ }
408
+
409
+ const USAGE =
410
+ 'murk — MCP server for your AI agent + scan your local apps with MURK’s hosted runner.\n' +
411
+ '\n' +
412
+ 'usage:\n' +
413
+ ' murk login browser sign-in; authorizes this machine\n' +
414
+ ' murk logout revoke this machine’s credential\n' +
415
+ ' murk whoami show login status\n' +
416
+ ' murk apps list your applications (id, name, source)\n' +
417
+ ' murk add <owner/repo> [--name NAME] add an app from a connected GitHub repo\n' +
418
+ ' murk scan <http://localhost:PORT> [--pr|--full] one-shot: connect, scan, disconnect (default: full)\n' +
419
+ ' murk mcp [--expose <http://localhost:PORT> ...] run the MCP server for your agent; exposes local apps\n' +
420
+ ' murk connect --expose <http://localhost:PORT> open the tunnel only (no MCP)\n' +
421
+ '\n' +
422
+ 'scan depth:\n' +
423
+ ' --full deep whole-repo audit with the model council (~45 min) [default]\n' +
424
+ ' --pr fast, cheaper scan, no council (~10 min)\n' +
425
+ '\n' +
426
+ 'env:\n' +
427
+ ' MURK_API_URL API origin (default https://api.murk.sh)\n' +
428
+ ' MURK_DASHBOARD_URL dashboard origin for login (default https://app.murk.sh)\n' +
429
+ ' MURK_API_TOKEN org API token (murk_…) for CI; overrides the stored login\n' +
430
+ ' MURK_NO_BROWSER set to print the login URL instead of opening a browser\n' +
431
+ '\n' +
432
+ 'agents: add the hosted MCP with claude mcp add --transport http murk https://api.murk.sh/mcp\n' +
433
+ ' or run it locally with murk mcp\n';
434
+
292
435
  function main() {
293
436
  const [cmd, ...rest] = process.argv.slice(2);
294
437
  const fatal = (e) => die(e instanceof Error ? e.message : String(e));
295
438
  if (cmd === 'login') {
296
439
  void cmdLogin().catch(fatal);
440
+ } else if (cmd === 'logout') {
441
+ void cmdLogout().catch(fatal);
442
+ } else if (cmd === 'whoami') {
443
+ void cmdWhoami().catch(fatal);
444
+ } else if (cmd === 'add') {
445
+ void cmdAdd(firstPositional(rest), parseFlagValue(rest, '--name')).catch(fatal);
446
+ } else if (cmd === 'apps' || cmd === 'ls') {
447
+ void cmdApps().catch(fatal);
297
448
  } else if (cmd === 'mcp') {
298
449
  void cmdMcp(parseExposed(rest)).catch(fatal);
299
450
  } else if (cmd === 'connect') {
300
451
  void cmdConnect(parseExposed(rest)).catch(fatal);
301
452
  } else if (cmd === 'scan') {
302
- void cmdScan(rest.find((a) => a.startsWith('http'))).catch(fatal);
453
+ void cmdScan(rest.find((a) => a.startsWith('http')), parseScanProfile(rest)).catch(fatal);
303
454
  } 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);
455
+ const unknown = cmd && cmd !== 'help' && cmd !== '-h' && cmd !== '--help';
456
+ if (unknown) process.stderr.write(`murk: unknown command "${cmd}"\n\n`);
457
+ process.stdout.write(USAGE);
458
+ process.exit(unknown ? 1 : 0);
312
459
  }
313
460
  }
314
461
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@murk-security/cli",
3
- "version": "0.3.2",
3
+ "version": "0.5.0",
4
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
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,8 @@
8
8
  },
9
9
  "files": [
10
10
  "murk.mjs",
11
- "mcp-server.mjs"
11
+ "mcp-server.mjs",
12
+ "README.md"
12
13
  ],
13
14
  "keywords": [
14
15
  "murk",