@shomra/agent 0.2.12 → 0.3.1

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.
Files changed (3) hide show
  1. package/README.md +54 -4
  2. package/package.json +2 -2
  3. package/shomra.mjs +394 -166
package/shomra.mjs CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Shomra agent — the developer-machine plugin for the Shomra AI Security
4
- * Posture Management platform. Discovers the AI tooling on this machine
5
- * (MCP servers, AI rules files, AI tools, model keys), and reports it to your
6
- * Shomra org for analysis. Zero dependencies Node built-ins only.
3
+ * Shomra agent — the developer-machine half of Shomra, the adversarial
4
+ * assurance platform for AI agents. Blocks dangerous tool-calls before they
5
+ * run, discovers the AI tooling on this machine (MCP servers, AI rules files,
6
+ * AI tools, model keys), and reports it to your Shomra org so the org can
7
+ * attack its own guardrails and prove they hold. Zero deps — Node built-ins.
7
8
  *
8
9
  * shomra init --key shm_live_… --url <your backend> # connect to a Shomra org (optional)
9
10
  * shomra scan # discover + analyze, print a local report
@@ -75,6 +76,22 @@ function resolveSettings(cfg) {
75
76
  };
76
77
  }
77
78
 
79
+ // ── exit-code convention (one convention for every command) ──────
80
+ // 0 = clean / pass
81
+ // 1 = hard fail (BLOCK, vulnerable model, secret found, FAIL verdict,
82
+ // below --min, regression)
83
+ // 2 = soft fail (FLAG under --strict, REVIEW when strict)
84
+ // 3 = usage/config error (not configured, bad flags, unknown command)
85
+ const EXIT_USAGE = 3;
86
+
87
+ // One shared "not configured" error — this command needs a backend + key.
88
+ // Prints where to get a key and exits with the usage/config code.
89
+ function exitNotConfigured() {
90
+ console.error('\n' + red('✗') + ' Not configured. Run ' + bold('shomra init --key shm_live_… --url <your backend>') + ' first.');
91
+ console.error(' ' + dim('Get a key in the Shomra app → Settings → API Keys.'));
92
+ process.exit(EXIT_USAGE);
93
+ }
94
+
78
95
  // ── guard latency budget + circuit breaker ───────────────────────
79
96
  // The PreToolUse/PostToolUse guards run on EVERY tool call in a fresh process,
80
97
  // so they must be snappy and self-healing when the backend is slow or down.
@@ -183,17 +200,49 @@ async function api(url, key, route, body, opts = {}) {
183
200
  return json;
184
201
  }
185
202
 
203
+ // Flags that are ON/OFF switches — they must NEVER consume the next token, or
204
+ // `shomra check --strict Shomra.Agent` silently eats the directory and scans
205
+ // the CWD, and `shomra gate --json file.md` errors "usage". Built from every
206
+ // boolean `flags.…` read in this file.
207
+ const BOOLEAN_FLAGS = new Set([
208
+ 'strict', 'json', 'sarif', 'fix', 'staged', 'changed', 'all', 'history', 'force',
209
+ 'apply', 'dry-run', 'global', 'local', 'trailer', 'evolve', 'report', 'init',
210
+ 'no-suppress', 'no-baseline', 'no-policy', 'no-index', 'adaptive',
211
+ 'fail-on-regression', 'fail-on-blocked', 'write', 'yes', 'stdin', 'quiet', 'help',
212
+ ]);
213
+ // Flags that take a value (`--key value` or `--key=value`).
214
+ const VALUE_FLAGS = new Set([
215
+ 'key', 'url', 'path', 'kind', 'name', 'project', 'agent', 'agent-id', 'min',
216
+ 'scenarios', 'objectives', 'turns', 'target', 'run', 'port', 'config', 'env',
217
+ 'command', 'base', 'repo', 'pr', 'token', 'sha', 'session', 'since', 'depth',
218
+ 'scope', 'writer', 'type', 'slug',
219
+ ]);
220
+ const KNOWN_FLAGS = new Set([...BOOLEAN_FLAGS, ...VALUE_FLAGS]);
221
+
186
222
  function parseFlags(argv) {
187
223
  const flags = {};
188
224
  const positional = [];
225
+ const unknown = [];
189
226
  for (let i = 0; i < argv.length; i++) {
190
227
  const a = argv[i];
191
228
  if (a.startsWith('--')) {
192
229
  const body = a.slice(2);
193
- // Support both `--key value` and `--key=value`.
230
+ // Support both `--key value` and `--key=value`. The `=` form always wins
231
+ // (even for boolean flags — `--sarif=out.sarif` opts into a value).
194
232
  const eq = body.indexOf('=');
195
233
  if (eq !== -1) {
196
- flags[body.slice(0, eq)] = body.slice(eq + 1);
234
+ const name = body.slice(0, eq);
235
+ if (!KNOWN_FLAGS.has(name)) unknown.push(name);
236
+ flags[name] = body.slice(eq + 1);
237
+ continue;
238
+ }
239
+ if (!KNOWN_FLAGS.has(body)) {
240
+ unknown.push(body);
241
+ flags[body] = true; // never consume a token for an unknown flag
242
+ continue;
243
+ }
244
+ if (BOOLEAN_FLAGS.has(body)) {
245
+ flags[body] = true; // boolean — never consume the next token
197
246
  continue;
198
247
  }
199
248
  const next = argv[i + 1];
@@ -203,7 +252,33 @@ function parseFlags(argv) {
203
252
  } else flags[body] = true;
204
253
  } else positional.push(a);
205
254
  }
206
- return { flags, positional };
255
+ return { flags, positional, unknown };
256
+ }
257
+
258
+ // Smallest edit distance — powers "did you mean …?" for commands and flags.
259
+ function levenshtein(a, b) {
260
+ const m = a.length, n = b.length;
261
+ if (!m) return n;
262
+ if (!n) return m;
263
+ let prev = Array.from({ length: n + 1 }, (_, j) => j);
264
+ for (let i = 1; i <= m; i++) {
265
+ const cur = [i];
266
+ for (let j = 1; j <= n; j++) {
267
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
268
+ }
269
+ prev = cur;
270
+ }
271
+ return prev[n];
272
+ }
273
+ function didYouMean(input, candidates) {
274
+ const s = String(input).toLowerCase();
275
+ let best = null, bestDist = Infinity;
276
+ for (const c of candidates) {
277
+ if (c.startsWith(s) || s.startsWith(c)) return c; // prefix match wins outright
278
+ const d = levenshtein(s, c);
279
+ if (d < bestDist) { bestDist = d; best = c; }
280
+ }
281
+ return bestDist <= Math.max(2, Math.floor(s.length / 3)) ? best : null;
207
282
  }
208
283
 
209
284
  // ── commands ─────────────────────────────────────────────────────
@@ -215,11 +290,11 @@ async function cmdInit(flags) {
215
290
  const url = (flags.url || cfg.url || '').replace(/\/$/, '');
216
291
  if (!key) {
217
292
  console.error(red('✗') + ' Missing API key. Run: ' + bold('shomra init --key shm_live_… --url <your backend>'));
218
- process.exit(1);
293
+ process.exit(EXIT_USAGE);
219
294
  }
220
295
  if (!url) {
221
296
  console.error(red('✗') + ' Missing backend URL. Run: ' + bold('shomra init --key shm_live_… --url <your backend>'));
222
- process.exit(1);
297
+ process.exit(EXIT_USAGE);
223
298
  }
224
299
  cfg.apiKey = key;
225
300
  cfg.url = url;
@@ -284,8 +359,7 @@ async function cmdScan(flags) {
284
359
  async function sendReport(cfg, assets, flags) {
285
360
  const { apiKey, url } = resolveSettings(cfg);
286
361
  if (!apiKey) {
287
- console.error('\n' + red('✗') + ' Not configured. Run ' + bold('shomra init --key shm_live_…') + ' first.');
288
- process.exit(1);
362
+ exitNotConfigured();
289
363
  }
290
364
  process.stdout.write(dim('\n Reporting to platform… '));
291
365
  try {
@@ -314,7 +388,7 @@ async function sendReport(cfg, assets, flags) {
314
388
  : green('No high-severity findings. ') + dim('Nice and clean.')),
315
389
  );
316
390
  console.log(dim(` Endpoint: ${res.endpointId}\n`));
317
- if (crit > 0) process.exitCode = 2;
391
+ if (crit > 0) process.exitCode = 1; // criticals are a hard fail
318
392
  } catch (e) {
319
393
  console.log(red('failed'));
320
394
  console.error(` ${red('✗')} ${e.message}\n`);
@@ -344,7 +418,7 @@ function cmdStatus() {
344
418
  console.log(` ${dim('Mode ')} ${cyan('● Local')} ${dim('— on-machine analysis only; nothing leaves this machine')}`);
345
419
  console.log(` ${dim(' ')} ${dim('Run')} ${bold('shomra init --key shm_…')} ${dim('to add org policy, AI fixes, deep scans & the dashboard.')}`);
346
420
  }
347
- console.log(` ${dim('Backend ')} ${url}`);
421
+ console.log(` ${dim('Backend ')} ${url || dim('none (local mode — set with shomra init --url)')}`);
348
422
  console.log(` ${dim('API key ')} ${apiKey ? green(apiKey.slice(0, 14) + '…') : dim('none (local mode)')}`);
349
423
  console.log(` ${dim('Machine ')} ${os.hostname()} ${dim('(' + (cfg.machineId || 'unenrolled') + ')')}`);
350
424
  console.log(` ${dim('Config ')} ${CONFIG_FILE}`);
@@ -355,21 +429,24 @@ function cmdStatus() {
355
429
  console.log(` ${enrolled ? green('✓') : gray('○')} ${(enrolled ? dim : gray)('fix (AI) · deep scans (scan-zip/model-scan/memory-scan) · org policy · dashboard telemetry')}`);
356
430
 
357
431
  // Runtime firewall health — is the guard wired in, and is it in a state that
358
- // could freeze the agent? (checks Claude Code's global + project settings).
359
- const hookFiles = [
360
- path.join(os.homedir(), '.claude', 'settings.json'),
361
- path.join(process.cwd(), '.claude', 'settings.json'),
362
- ].filter((f) => {
363
- try {
364
- return fs.readFileSync(f, 'utf8').includes('shomra tool-guard');
365
- } catch {
366
- return false;
367
- }
368
- });
432
+ // could freeze the agent? Checks EVERY supported agent's config files (the
433
+ // same paths install-hook writes), matching both the legacy bare `shomra
434
+ // tool-guard` form and the absolute `node …shomra.mjs tool-guard` form.
369
435
  const localOff = process.env.SHOMRA_GUARD_LOCAL === '0' || String(process.env.SHOMRA_GUARD_LOCAL).toLowerCase() === 'false';
370
436
  const strict = envFlag('SHOMRA_GUARD_STRICT');
371
437
  console.log(bold('\n Runtime firewall'));
372
- console.log(` ${dim('Hook ')} ${hookFiles.length ? green('installed') + dim(' ' + hookFiles.join(', ')) : yellow('not installed') + dim(' (run: shomra install-hook)')}`);
438
+ const installedAgents = AGENT_KEYS.map((a) => ({ agent: a, files: agentHookInstalled(a) })).filter((x) => x.files.length);
439
+ if (installedAgents.length) {
440
+ let first = true;
441
+ for (const { agent, files } of installedAgents) {
442
+ console.log(` ${dim(first ? 'Hooks ' : ' ')} ${green('installed')} ${bold(AGENT_LABELS[agent])} ${dim('→ ' + files.join(', '))}`);
443
+ first = false;
444
+ }
445
+ const missing = AGENT_KEYS.filter((a) => !installedAgents.some((i) => i.agent === a));
446
+ if (missing.length) console.log(` ${dim(' ')} ${dim('not installed: ' + missing.map((m) => AGENT_LABELS[m]).join(', '))}`);
447
+ } else {
448
+ console.log(` ${dim('Hooks ')} ${yellow('not installed for any agent')}${dim(' (run: shomra install-hook --agent all or shomra protect)')}`);
449
+ }
373
450
  console.log(` ${dim('Tier 0 ')} ${localOff ? yellow('off') + dim(' (server-only)') : green('on') + dim(' — dangerous calls blocked on-machine, zero network')}`);
374
451
  console.log(` ${dim('Mode ')} ${strict ? 'fail-closed (strict)' : 'fail-open'}${dim(` · server timeout ${guardTimeoutMs()}ms · breaker ${breakerCooldownMs()}ms`)}`);
375
452
  console.log(` ${dim('Breaker ')} ${breakerOpen() ? red('OPEN') + dim(' — backend recently unreachable; server tier is being skipped') : green('closed')}\n`);
@@ -501,7 +578,7 @@ async function cmdGate(flags, positional) {
501
578
  } else {
502
579
  if (!file) {
503
580
  console.error(red('✗') + ' Usage: ' + bold('shomra gate <file> [--kind mcp|skill|command|subagent|hook|rules|agent-card|memory] [--name x] [--strict] [--json]'));
504
- process.exit(1);
581
+ process.exit(EXIT_USAGE);
505
582
  }
506
583
  let target = path.resolve(String(file));
507
584
  // A directory gates its SKILL.md (the skill-install case).
@@ -509,13 +586,13 @@ async function cmdGate(flags, positional) {
509
586
  const skillMd = path.join(target, 'SKILL.md');
510
587
  if (!fs.existsSync(skillMd)) {
511
588
  console.error(red('✗') + ` ${file} is a directory with no SKILL.md — point at a file instead.`);
512
- process.exit(1);
589
+ process.exit(EXIT_USAGE);
513
590
  }
514
591
  target = skillMd;
515
592
  }
516
593
  if (!fs.existsSync(target)) {
517
594
  console.error(red('✗') + ` File not found: ${file}`);
518
- process.exit(1);
595
+ process.exit(EXIT_USAGE);
519
596
  }
520
597
  content = fs.readFileSync(target, 'utf8');
521
598
  relPath = path.relative(process.cwd(), target).split(path.sep).join('/');
@@ -599,8 +676,7 @@ async function cmdLlmProxy(flags) {
599
676
  const cfg = loadConfig();
600
677
  const { apiKey, url } = resolveSettings(cfg);
601
678
  if (!apiKey) {
602
- console.error('\n' + red('✗') + ' Not configured. Run ' + bold('shomra init --key shm_live_…') + ' first.');
603
- process.exit(1);
679
+ exitNotConfigured();
604
680
  }
605
681
  const port = parseInt(flags.port, 10) || 4141;
606
682
  const project = flags.project ? String(flags.project) : null;
@@ -614,7 +690,11 @@ async function cmdLlmProxy(flags) {
614
690
 
615
691
  const providerRe = new RegExp(`^/(${LLM_PROVIDERS.join('|')})(/.*)?$`);
616
692
  const server = createServer(async (req, res) => {
617
- const m = String(req.url).match(providerRe);
693
+ // Back-compat: older integrations (an .aider.conf.yml written by a previous
694
+ // install-hook) pointed at /llm/<provider>/… — strip the /llm prefix so
695
+ // already-written configs keep working against the /<provider>/… routes.
696
+ const reqUrl = String(req.url).replace(/^\/llm(?=\/)/, '');
697
+ const m = reqUrl.match(providerRe);
618
698
  if (!m) {
619
699
  res.writeHead(404, { 'content-type': 'application/json' });
620
700
  res.end(JSON.stringify({ error: { message: `Unknown route — use /<provider>/… (providers: ${LLM_PROVIDERS.join(', ')})` } }));
@@ -706,6 +786,16 @@ const ARTIFACT_MATCHERS = [
706
786
  { kind: 'command', re: /(^|\/)\.claude\/commands\/[^/]+\.md$/i },
707
787
  { kind: 'subagent', re: /(^|\/)\.claude\/agents\/[^/]+\.md$/i },
708
788
  { kind: 'hook', re: /(^|\/)\.claude\/settings(\.local)?\.json$/i },
789
+ // Every other agent-config file install-hook writes is the same attack
790
+ // surface: a malicious hook planted there runs on every tool call. Gate them
791
+ // with the same hook/settings checks as .claude/settings.json.
792
+ { kind: 'hook', re: /(^|\/)\.cursor\/hooks\.json$/i },
793
+ { kind: 'hook', re: /(^|\/)(\.windsurf|\.codeium\/windsurf)\/hooks\.json$/i },
794
+ { kind: 'hook', re: /(^|\/)\.gemini\/settings\.json$/i },
795
+ { kind: 'hook', re: /(^|\/)\.cline\/hooks\.json$/i },
796
+ { kind: 'hook', re: /(^|\/)\.codex\/hooks\.json$/i },
797
+ { kind: 'hook', re: /(^|\/)(\.github|\.copilot)\/hooks\/[^/]+\.json$/i },
798
+ { kind: 'hook', re: /(^|\/)\.aider\.conf\.yml$/i },
709
799
  { kind: 'agent-card', re: /(^|\/)\.well-known\/agent(-card)?\.json$/i },
710
800
  { kind: 'agent-card', re: /(^|\/)agent[-_]card\.json$/i },
711
801
  { kind: 'rules', re: /(^|\/)(CLAUDE|AGENTS|GEMINI|CONVENTIONS)\.md$/i },
@@ -1085,7 +1175,9 @@ async function gateArtifactList(artifacts, { apiKey, url, env, flags, root }) {
1085
1175
  const { a, content, local, sast } = prepared[i];
1086
1176
  const res = server[i];
1087
1177
  const source = res ? 'server' : 'local';
1088
- const merged = mergeSastIntoResult(res || localAsGateResult(local, a.rel, a.kind), sast);
1178
+ // Local results get a clean display name (basename) — the path is already
1179
+ // printed next to it, so name=rel printed the path twice per line.
1180
+ const merged = mergeSastIntoResult(res || localAsGateResult(local, a.rel.split('/').pop(), a.kind), sast);
1089
1181
  const r0 = { path: a.rel, full: a.full, kind: a.kind, source, ...merged };
1090
1182
  const rs = suppress ? suppressResult(r0, rules, baseline, lineCache) : r0;
1091
1183
  const r = applyRepoPolicy(rs, policy);
@@ -1096,10 +1188,15 @@ async function gateArtifactList(artifacts, { apiKey, url, env, flags, root }) {
1096
1188
  if (!quiet) {
1097
1189
  const dc = r.decision === 'BLOCK' ? red : r.decision === 'FLAG' ? yellow : green;
1098
1190
  const supNote = r.suppressedCount ? dim(` · ${r.suppressedCount} suppressed`) : '';
1099
- console.log(` ${dc('●')} ${bold(r.name)} ${dim(a.rel)}${source === 'local' ? dim(' ·local') : ''} ${dc(r.decision)} ${dim('risk ' + r.riskScore + ' · ' + (r.findingCount ?? (r.findings || []).length) + ' finding(s)')}${supNote}`);
1100
- for (const f of (r.findings || []).slice(0, 3)) {
1101
- console.log(` ${SEV_COLOR[f.severity](String(f.severity).padEnd(8))} ${f.title}`);
1191
+ const pathNote = a.rel !== r.name ? ' ' + dim(a.rel) : ''; // don't print the path twice
1192
+ console.log(` ${dc('●')} ${bold(r.name)}${pathNote}${source === 'local' ? dim(' ·local') : ''} ${dc(r.decision)} ${dim('risk ' + r.riskScore + ' · ' + (r.findingCount ?? (r.findings || []).length) + ' finding(s)')}${supNote}`);
1193
+ const shown = (r.findings || []).slice(0, 3);
1194
+ for (const f of shown) {
1195
+ const loc = f.line ? dim(` (${f.file || a.rel}:${f.line})`) : '';
1196
+ console.log(` ${SEV_COLOR[f.severity](String(f.severity).padEnd(8))} ${f.title}${loc}`);
1102
1197
  }
1198
+ const more = (r.findings || []).length - shown.length;
1199
+ if (more > 0) console.log(` ${dim(`… and ${more} more (run with --json for all)`)}`);
1103
1200
  }
1104
1201
  }
1105
1202
  return { results, blocked, flagged, suppressed, backendDown };
@@ -1362,7 +1459,7 @@ const GH_LEVEL = { CRITICAL: 'failure', HIGH: 'failure', MEDIUM: 'warning', LOW:
1362
1459
  async function cmdPr(flags, positional) {
1363
1460
  if (flags.init) {
1364
1461
  const wf = path.resolve('.github/workflows/shomra.yml');
1365
- if (fs.existsSync(wf) && !flags.force) { console.error(red('✗') + ` ${path.relative(process.cwd(), wf)} exists. Use ${bold('--force')}.`); process.exit(1); }
1462
+ if (fs.existsSync(wf) && !flags.force) { console.error(red('✗') + ` ${path.relative(process.cwd(), wf)} exists. Use ${bold('--force')}.`); process.exit(EXIT_USAGE); }
1366
1463
  fs.mkdirSync(path.dirname(wf), { recursive: true });
1367
1464
  fs.writeFileSync(wf, PR_WORKFLOW);
1368
1465
  console.log(`\n ${green('✓ Wrote')} ${bold('.github/workflows/shomra.yml')} ${dim('— commit it; PRs will get an inline Shomra review.')}`);
@@ -1381,8 +1478,8 @@ async function cmdPr(flags, positional) {
1381
1478
  const root = path.resolve(flags.path || '.');
1382
1479
  const dryRun = !!flags['dry-run'];
1383
1480
 
1384
- if (!repo || !headSha) { console.error(red('✗') + ' Not in a GitHub PR context (need GITHUB_REPOSITORY + a head sha). Pass --repo / --sha, or use --dry-run.'); process.exit(1); }
1385
- if (!token && !dryRun) { console.error(red('✗') + ' No GitHub token. Set GITHUB_TOKEN (CI) or --token, or preview with --dry-run.'); process.exit(1); }
1481
+ if (!repo || !headSha) { console.error(red('✗') + ' Not in a GitHub PR context (need GITHUB_REPOSITORY + a head sha). Pass --repo / --sha, or use --dry-run.'); process.exit(EXIT_USAGE); }
1482
+ if (!token && !dryRun) { console.error(red('✗') + ' No GitHub token. Set GITHUB_TOKEN (CI) or --token, or preview with --dry-run.'); process.exit(EXIT_USAGE); }
1386
1483
 
1387
1484
  // Gate the CHANGED artifacts (fall back to the whole tree if the diff won't resolve).
1388
1485
  const changed = gitChangedVsBase(root, base);
@@ -1390,13 +1487,27 @@ async function cmdPr(flags, positional) {
1390
1487
  const artifacts = changed === null ? all : all.filter((a) => new Set(changed).has(a.rel));
1391
1488
  const env = detectEnv();
1392
1489
 
1490
+ // --sarif: emit SARIF 2.1.0 for the changed artifacts. Bare `--sarif` writes
1491
+ // to stdout; `--sarif=<file>` writes the file (so it can coexist with the
1492
+ // human/check-run output).
1493
+ const emitSarif = (results) => {
1494
+ if (!flags.sarif) return;
1495
+ const sarif = JSON.stringify(toSarif(results), null, 2);
1496
+ if (typeof flags.sarif === 'string') {
1497
+ fs.writeFileSync(path.resolve(String(flags.sarif)), sarif);
1498
+ if (!flags.json) console.error(dim(` SARIF written → ${flags.sarif}`));
1499
+ } else console.log(sarif);
1500
+ };
1501
+
1393
1502
  if (!artifacts.length) {
1394
- if (!flags.json) console.log(green('\n ✓ No AI artifacts changed in this PR.\n'));
1503
+ emitSarif([]);
1504
+ if (!flags.json && flags.sarif !== true) console.log(green('\n ✓ No AI artifacts changed in this PR.\n'));
1395
1505
  if (token && !dryRun) await githubApi(token, 'POST', `/repos/${repo}/check-runs`, { name: 'Shomra AI Security', head_sha: headSha, status: 'completed', conclusion: 'success', output: { title: 'No AI artifacts changed', summary: 'No MCP configs, skills, rules, hooks or agent cards changed in this PR.' } }).catch((e) => console.error(dim(' check-run: ' + e.message)));
1396
1506
  return;
1397
1507
  }
1398
1508
 
1399
1509
  const { results, blocked, flagged, suppressed } = await gateArtifactList(artifacts, { apiKey, url, env, flags: { ...flags, json: true }, root });
1510
+ emitSarif(results);
1400
1511
 
1401
1512
  // Build inline annotations (GitHub caps a check-run at 50 per request).
1402
1513
  const annotations = [];
@@ -1430,7 +1541,7 @@ async function cmdPr(flags, positional) {
1430
1541
 
1431
1542
  if (dryRun || flags.json) {
1432
1543
  console.log(JSON.stringify({ repo, prNumber: prNumber ?? null, headSha, base, conclusion, artifacts: results.length, findings: annotations.length, checkRun: dryRun ? checkRun : undefined }, null, 2));
1433
- } else {
1544
+ } else if (flags.sarif !== true) { // bare --sarif already owns stdout
1434
1545
  console.log(bold(cyan('\n Shomra pr')) + dim(` — ${repo} #${prNumber ?? '?'} · ${results.length} changed artifact(s) · ${annotations.length} finding(s)`));
1435
1546
  }
1436
1547
 
@@ -1460,27 +1571,27 @@ async function cmdFix(flags, positional) {
1460
1571
  const file = positional[0];
1461
1572
  if (!file) {
1462
1573
  console.error(red('✗') + ' Usage: ' + bold('shomra fix <file> [--apply] [--kind mcp|skill|command|subagent|hook|rules] [--json]'));
1463
- process.exit(1);
1574
+ process.exit(EXIT_USAGE);
1464
1575
  }
1465
1576
  const cfg = loadConfig();
1466
1577
  const { apiKey, url } = resolveSettings(cfg);
1467
1578
  if (!apiKey) {
1468
1579
  console.error('\n' + red('✗') + ' ' + bold('shomra fix') + ' needs enrollment — the fix is generated on the platform with your org AI key.');
1469
1580
  console.error(' ' + dim('Run ') + bold('shomra init --key shm_live_…') + dim(', or apply the guidance from ') + bold('shomra check') + dim(' by hand.\n'));
1470
- process.exit(1);
1581
+ process.exit(EXIT_USAGE);
1471
1582
  }
1472
1583
  let target = path.resolve(String(file));
1473
1584
  if (fs.existsSync(target) && fs.statSync(target).isDirectory()) {
1474
1585
  const skillMd = path.join(target, 'SKILL.md');
1475
1586
  if (!fs.existsSync(skillMd)) {
1476
1587
  console.error(red('✗') + ` ${file} is a directory with no SKILL.md — point at a file instead.`);
1477
- process.exit(1);
1588
+ process.exit(EXIT_USAGE);
1478
1589
  }
1479
1590
  target = skillMd;
1480
1591
  }
1481
1592
  if (!fs.existsSync(target)) {
1482
1593
  console.error(red('✗') + ` File not found: ${file}`);
1483
- process.exit(1);
1594
+ process.exit(EXIT_USAGE);
1484
1595
  }
1485
1596
  await fixOneFile(target, { apiKey, url, flags });
1486
1597
  }
@@ -1578,20 +1689,20 @@ async function cmdWhy(flags, positional) {
1578
1689
  const file = positional[0];
1579
1690
  if (!file) {
1580
1691
  console.error(red('✗') + ' Usage: ' + bold('shomra why <file> [--kind mcp|skill|command|subagent|hook|rules] [--json]'));
1581
- process.exit(1);
1692
+ process.exit(EXIT_USAGE);
1582
1693
  }
1583
1694
  let target = path.resolve(String(file));
1584
1695
  if (fs.existsSync(target) && fs.statSync(target).isDirectory()) {
1585
1696
  const skillMd = path.join(target, 'SKILL.md');
1586
1697
  if (!fs.existsSync(skillMd)) {
1587
1698
  console.error(red('✗') + ` ${file} is a directory with no SKILL.md — point at a file instead.`);
1588
- process.exit(1);
1699
+ process.exit(EXIT_USAGE);
1589
1700
  }
1590
1701
  target = skillMd;
1591
1702
  }
1592
1703
  if (!fs.existsSync(target)) {
1593
1704
  console.error(red('✗') + ` File not found: ${file}`);
1594
- process.exit(1);
1705
+ process.exit(EXIT_USAGE);
1595
1706
  }
1596
1707
  const content = fs.readFileSync(target, 'utf8');
1597
1708
  const rel = path.relative(process.cwd(), target).split(path.sep).join('/');
@@ -1700,7 +1811,7 @@ async function cmdProvenance(flags, positional) {
1700
1811
  const paths = gitChangedPaths(root, { staged, base });
1701
1812
  if (paths === null) {
1702
1813
  console.error(red('✗') + ' Not a git repository (or no diff available). Run inside a repo, or pass --base <ref>.');
1703
- process.exit(1);
1814
+ process.exit(EXIT_USAGE);
1704
1815
  }
1705
1816
  if (!paths.length) {
1706
1817
  if (flags.json) console.log(JSON.stringify({ files: [], agentAuthored: 0, coverage: 'NO_TELEMETRY', summary: 'no changed files' }, null, 2));
@@ -1719,10 +1830,11 @@ async function cmdProvenance(flags, positional) {
1719
1830
  sinceHours: flags.since ? Number(flags.since) : undefined,
1720
1831
  });
1721
1832
  } catch (e) {
1722
- // Provenance is an evidence lookup, not a guard — a backend outage must not
1723
- // block a commit. Say so plainly instead of silently reporting "no agents".
1833
+ // Provenance is an evidence lookup, not a guard — but exiting 0 here was a
1834
+ // green build that proved nothing. Exit with the config-error code so CI
1835
+ // can tell "authorship established: none" from "could not even look".
1724
1836
  console.error(yellow('!') + ` Provenance unavailable (${e.message}). Authorship not established.`);
1725
- process.exit(flags['fail-on-blocked'] ? 1 : 0);
1837
+ process.exit(flags['fail-on-blocked'] ? 1 : EXIT_USAGE);
1726
1838
  }
1727
1839
 
1728
1840
  if (flags.json) {
@@ -1770,7 +1882,7 @@ async function cmdInstallPrecommit(flags, positional) {
1770
1882
  const hooksDir = gitHooksDir(root);
1771
1883
  if (!hooksDir) {
1772
1884
  console.error(red('✗') + ' Not a git repository (or git unavailable). cd into your repo first.');
1773
- process.exit(1);
1885
+ process.exit(EXIT_USAGE);
1774
1886
  }
1775
1887
  const hookPath = path.join(hooksDir, 'pre-commit');
1776
1888
  const marker = 'shomra check --staged';
@@ -1778,7 +1890,18 @@ async function cmdInstallPrecommit(flags, positional) {
1778
1890
  '#!/bin/sh',
1779
1891
  '# Shomra — block staged AI artifacts that fail the gate before they land.',
1780
1892
  '# Managed by `shomra install-precommit`. Delete this file to uninstall.',
1781
- 'command -v shomra >/dev/null 2>&1 || { echo "shomra not on PATH skipping AI-artifact gate"; exit 0; }',
1893
+ // Skipping is the safe choice (never brick a commit), but it must be LOUD:
1894
+ // a silent skip is a gate everyone believes ran.
1895
+ 'command -v shomra >/dev/null 2>&1 || {',
1896
+ ' echo "" >&2',
1897
+ ' echo "!! ============================================================== !!" >&2',
1898
+ ' echo "!! WARNING: shomra not on PATH — the AI-artifact gate DID NOT RUN !!" >&2',
1899
+ ' echo "!! Staged MCP/skill/rules files were committed UNGATED. !!" >&2',
1900
+ ' echo "!! Fix: npm i -g @shomra/agent (then re-commit to gate) !!" >&2',
1901
+ ' echo "!! ============================================================== !!" >&2',
1902
+ ' echo "" >&2',
1903
+ ' exit 0',
1904
+ '}',
1782
1905
  'shomra check --staged',
1783
1906
  'if [ "$?" -eq 1 ]; then',
1784
1907
  ' echo "✗ Shomra blocked a staged AI artifact — run: shomra fix <file> --apply (or: git commit --no-verify to override)"',
@@ -1837,28 +1960,27 @@ function gitHooksDir(root) {
1837
1960
  // Uploads the archive to the platform's Workspace Scan (static analysis only —
1838
1961
  // nothing in the archive is executed) and prints the per-kind report: Skills,
1839
1962
  // slash commands, subagents, hooks, MCP configs, rules files, secret files.
1840
- // Exit codes: 0 = PASS/REVIEW, 2 = FAIL.
1963
+ // Exit codes: 0 = PASS/REVIEW, 1 = FAIL or policy BLOCK, 2 = policy FLAG with --strict.
1841
1964
 
1842
1965
  async function cmdScanZip(flags, positional) {
1843
1966
  const cfg = loadConfig();
1844
1967
  const { apiKey, url } = resolveSettings(cfg);
1845
1968
  if (!apiKey) {
1846
- console.error('\n' + red('✗') + ' Not configured. Run ' + bold('shomra init --key shm_live_…') + ' first.');
1847
- process.exit(1);
1969
+ exitNotConfigured();
1848
1970
  }
1849
1971
  const file = positional[0];
1850
1972
  if (!file) {
1851
1973
  console.error(red('✗') + ' Usage: ' + bold('shomra scan-zip <workspace.zip> [--project <id>] [--json]'));
1852
- process.exit(1);
1974
+ process.exit(EXIT_USAGE);
1853
1975
  }
1854
1976
  const target = path.resolve(String(file));
1855
1977
  if (!fs.existsSync(target) || !fs.statSync(target).isFile()) {
1856
1978
  console.error(red('✗') + ` File not found: ${file}`);
1857
- process.exit(1);
1979
+ process.exit(EXIT_USAGE);
1858
1980
  }
1859
1981
  if (!/\.zip$/i.test(target)) {
1860
1982
  console.error(red('✗') + ` ${file} is not a .zip archive.`);
1861
- process.exit(1);
1983
+ process.exit(EXIT_USAGE);
1862
1984
  }
1863
1985
 
1864
1986
  const buf = fs.readFileSync(target);
@@ -1926,11 +2048,10 @@ async function cmdScanZip(flags, positional) {
1926
2048
  dim(' Full report in the Shomra dashboard → Workspace Scan.\n'),
1927
2049
  );
1928
2050
  }
1929
- // Org policy takes precedence for CI: a BLOCK fails the build (exit 1), above
1930
- // the severity-only FAIL (exit 2). A policy FLAG fails only with --strict.
1931
- if (res.policyDecision === 'BLOCK') process.exitCode = 1;
1932
- else if (res.verdict === 'FAIL') process.exitCode = 2;
1933
- else if (res.policyDecision === 'FLAG' && flags.strict) process.exitCode = 2;
2051
+ // Hard fails (policy BLOCK, severity FAIL) exit 1; a policy FLAG or a REVIEW
2052
+ // verdict is a soft fail and exits 2 only with --strict.
2053
+ if (res.policyDecision === 'BLOCK' || res.verdict === 'FAIL') process.exitCode = 1;
2054
+ else if ((res.policyDecision === 'FLAG' || res.verdict === 'REVIEW') && flags.strict) process.exitCode = 2;
1934
2055
  }
1935
2056
 
1936
2057
  // ── model SAST scan: analyze a public AI model's source code ─────────
@@ -1941,22 +2062,21 @@ async function cmdScanZip(flags, positional) {
1941
2062
  // API or a shallow GitHub clone — never the weights) and runs SAST over its
1942
2063
  // .py files + config.json, plus provenance/weight/card checks. Prints the
1943
2064
  // per-asset findings with rule id, file:line and code snippet. Nothing is
1944
- // executed. Exit codes: 0 = PASS/REVIEW, 2 = FAIL.
2065
+ // executed. Exit codes: 0 = PASS/REVIEW, 1 = FAIL.
1945
2066
 
1946
2067
  async function cmdModelScan(flags, positional) {
1947
2068
  const cfg = loadConfig();
1948
2069
  const { apiKey, url } = resolveSettings(cfg);
1949
2070
  if (!apiKey) {
1950
- console.error('\n' + red('✗') + ' Not configured. Run ' + bold('shomra init --key shm_live_…') + ' first.');
1951
- process.exit(1);
2071
+ exitNotConfigured();
1952
2072
  }
1953
2073
  const target = positional[0];
1954
2074
  if (!target) {
1955
2075
  console.error(red('✗') + ' Usage: ' + bold('shomra model-scan <hf-url | owner/model | github-url> [--project <id>] [--json]'));
1956
- process.exit(1);
2076
+ process.exit(EXIT_USAGE);
1957
2077
  }
1958
2078
 
1959
- process.stdout.write(dim(`\n Scanning ${target}… `));
2079
+ if (!flags.json) process.stdout.write(dim(`\n Scanning ${target}… `));
1960
2080
  let res;
1961
2081
  try {
1962
2082
  res = await api(url, apiKey, '/projects/agent-model-scan', {
@@ -1965,15 +2085,16 @@ async function cmdModelScan(flags, positional) {
1965
2085
  ...(flags.project ? { projectId: String(flags.project) } : {}),
1966
2086
  });
1967
2087
  } catch (e) {
1968
- console.log(red('failed'));
2088
+ if (!flags.json) console.log(red('failed'));
1969
2089
  console.error(` ${red('✗')} ${e.message}\n`);
1970
2090
  process.exit(1);
1971
2091
  }
1972
- console.log(green('done'));
2092
+ if (!flags.json) console.log(green('done'));
1973
2093
 
1974
2094
  if (flags.json) {
1975
2095
  console.log(JSON.stringify(res, null, 2));
1976
- if (res.verdict === 'FAIL') process.exitCode = 2;
2096
+ if (res.verdict === 'FAIL') process.exitCode = 1;
2097
+ else if (res.verdict === 'REVIEW' && flags.strict) process.exitCode = 2;
1977
2098
  return;
1978
2099
  }
1979
2100
 
@@ -2024,7 +2145,8 @@ async function cmdModelScan(flags, positional) {
2024
2145
  dim(' Full report in the Shomra dashboard → Projects.\n'),
2025
2146
  );
2026
2147
 
2027
- if (res.verdict === 'FAIL') process.exitCode = 2;
2148
+ if (res.verdict === 'FAIL') process.exitCode = 1;
2149
+ else if (res.verdict === 'REVIEW' && flags.strict) process.exitCode = 2;
2028
2150
  }
2029
2151
 
2030
2152
  // Normalize a model-scan target (HF URL, owner/model, or github URL) to the
@@ -2048,7 +2170,7 @@ function hfModelIdFromTarget(target) {
2048
2170
  // staged payloads, exfil sinks — and reports each write to the platform with
2049
2171
  // provenance so the integrity timeline, drift detection and rollback work. Rules
2050
2172
  // files are graded against an instruction baseline (their path decides the mode).
2051
- // Point it at a repo/dir or a single file. Exit: 0 = clean/review, 2 = poisoned.
2173
+ // Point it at a repo/dir or a single file. Exit: 0 = clean/review, 1 = poisoned.
2052
2174
 
2053
2175
  const MEMORY_MATCHERS = [
2054
2176
  /(^|\/)MEMOR(Y|IES)\.(md|json|jsonl|txt)$/i,
@@ -2123,14 +2245,13 @@ async function cmdMemoryScan(flags, positional) {
2123
2245
  const cfg = loadConfig();
2124
2246
  const { apiKey, url } = resolveSettings(cfg);
2125
2247
  if (!apiKey) {
2126
- console.error('\n' + red('✗') + ' Not configured. Run ' + bold('shomra init --key shm_live_…') + ' first.');
2127
- process.exit(1);
2248
+ exitNotConfigured();
2128
2249
  }
2129
2250
  const targetArg = positional[0] || '.';
2130
2251
  const target = path.resolve(String(targetArg));
2131
2252
  if (!fs.existsSync(target)) {
2132
2253
  console.error(red('✗') + ` Not found: ${targetArg}`);
2133
- process.exit(1);
2254
+ process.exit(EXIT_USAGE);
2134
2255
  }
2135
2256
  const files = fs.statSync(target).isDirectory()
2136
2257
  ? walkMemoryFiles(target)
@@ -2145,7 +2266,7 @@ async function cmdMemoryScan(flags, positional) {
2145
2266
  const scope = flags.scope ? String(flags.scope).toLowerCase() : undefined;
2146
2267
  const writer = flags.writer ? String(flags.writer).toUpperCase() : 'AGENT';
2147
2268
  const actor = `${os.hostname()}/${os.userInfo().username}`;
2148
- console.log(bold(cyan('\n Shomra Memory Integrity')) + dim(` — scanning ${files.length} store${files.length > 1 ? 's' : ''}`));
2269
+ if (!flags.json) console.log(bold(cyan('\n Shomra Memory Integrity')) + dim(` — scanning ${files.length} store${files.length > 1 ? 's' : ''}`));
2149
2270
 
2150
2271
  let worst = 'PASS';
2151
2272
  const stores = [];
@@ -2154,7 +2275,7 @@ async function cmdMemoryScan(flags, positional) {
2154
2275
  try {
2155
2276
  const stat = fs.statSync(f.full);
2156
2277
  if (stat.size > MAX_ARTIFACT_BYTES) {
2157
- console.log(` ${gray('•')} ${dim(f.rel)} ${yellow('skipped (too large)')}`);
2278
+ if (!flags.json) console.log(` ${gray('•')} ${dim(f.rel)} ${yellow('skipped (too large)')}`);
2158
2279
  continue;
2159
2280
  }
2160
2281
  content = fs.readFileSync(f.full, 'utf8');
@@ -2174,7 +2295,7 @@ async function cmdMemoryScan(flags, positional) {
2174
2295
  ...(flags.project ? { projectId: String(flags.project) } : {}),
2175
2296
  });
2176
2297
  } catch (e) {
2177
- console.log(` ${red('✗')} ${f.rel} ${red('ingest error: ' + e.message)}`);
2298
+ console.error(` ${red('✗')} ${f.rel} ${red('ingest error: ' + e.message)}`);
2178
2299
  continue;
2179
2300
  }
2180
2301
  const v = res?.store?.verdict || 'PASS';
@@ -2182,18 +2303,20 @@ async function cmdMemoryScan(flags, positional) {
2182
2303
  else if (v === 'REVIEW' && worst !== 'FAIL') worst = 'REVIEW';
2183
2304
  stores.push({ path: f.rel, ...res });
2184
2305
 
2185
- const vc = VERDICT_COLOR[v] || gray;
2186
- const poison = res?.store?.poisonScore ?? 0;
2187
- const anom = res?.provenance?.anomalous;
2188
- console.log(
2189
- `\n ${vc('●')} ${bold(path.basename(f.rel))} ${dim(f.rel)} ${vc(v)} ${dim('poison ' + poison + '/100')}` +
2190
- (res?.quarantined ? ' ' + red('QUARANTINED') : '') +
2191
- (anom ? ' ' + red('OUT-OF-BAND WRITE') : ''),
2192
- );
2193
- for (const finding of (res?.analysis?.findings || []).filter((x) => x.severity !== 'INFO')) {
2194
- console.log(` ${SEV_COLOR[finding.severity](String(finding.severity).padEnd(8))} ${finding.title}`);
2306
+ if (!flags.json) {
2307
+ const vc = VERDICT_COLOR[v] || gray;
2308
+ const poison = res?.store?.poisonScore ?? 0;
2309
+ const anom = res?.provenance?.anomalous;
2310
+ console.log(
2311
+ `\n ${vc('●')} ${bold(path.basename(f.rel))} ${dim(f.rel)} ${vc(v)} ${dim('poison ' + poison + '/100')}` +
2312
+ (res?.quarantined ? ' ' + red('QUARANTINED') : '') +
2313
+ (anom ? ' ' + red('OUT-OF-BAND WRITE') : ''),
2314
+ );
2315
+ for (const finding of (res?.analysis?.findings || []).filter((x) => x.severity !== 'INFO')) {
2316
+ console.log(` ${SEV_COLOR[finding.severity](String(finding.severity).padEnd(8))} ${finding.title}`);
2317
+ }
2318
+ if (anom) console.log(` ${red('provenance:')} ${dim(res.provenance.reason)}`);
2195
2319
  }
2196
- if (anom) console.log(` ${red('provenance:')} ${dim(res.provenance.reason)}`);
2197
2320
  }
2198
2321
 
2199
2322
  if (flags.json) {
@@ -2209,7 +2332,8 @@ async function cmdMemoryScan(flags, positional) {
2209
2332
  dim(' Full timeline + rollback in the Shomra dashboard → Memory.\n'),
2210
2333
  );
2211
2334
  }
2212
- if (worst === 'FAIL') process.exitCode = 2;
2335
+ if (worst === 'FAIL') process.exitCode = 1;
2336
+ else if (worst === 'REVIEW' && flags.strict) process.exitCode = 2;
2213
2337
  }
2214
2338
 
2215
2339
  // ── continuous agentic red-teaming: prove your guardrails still hold ────
@@ -2227,13 +2351,12 @@ async function cmdRedteam(flags) {
2227
2351
  const cfg = loadConfig();
2228
2352
  const { apiKey, url } = resolveSettings(cfg);
2229
2353
  if (!apiKey) {
2230
- console.error('\n' + red('✗') + ' Not configured. Run ' + bold('shomra init --key shm_live_…') + ' first.');
2231
- process.exit(1);
2354
+ exitNotConfigured();
2232
2355
  }
2233
2356
  const targetKind = flags.target === 'model' ? 'model' : 'llm-guard';
2234
2357
  const scenarioKeys = typeof flags.scenarios === 'string' ? flags.scenarios.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
2235
2358
 
2236
- process.stdout.write(dim(`\n Red-teaming your ${targetKind === 'model' ? 'model' : 'LLM Guard'}… `));
2359
+ if (!flags.json) process.stdout.write(dim(`\n Red-teaming your ${targetKind === 'model' ? 'model' : 'LLM Guard'}… `));
2237
2360
  let run;
2238
2361
  try {
2239
2362
  run = await api(url, apiKey, '/redteam/agent-run', {
@@ -2244,11 +2367,11 @@ async function cmdRedteam(flags) {
2244
2367
  actor: `${os.hostname()}/${os.userInfo().username}`,
2245
2368
  });
2246
2369
  } catch (e) {
2247
- console.log(red('failed'));
2370
+ if (!flags.json) console.log(red('failed'));
2248
2371
  console.error(` ${red('✗')} ${e.message}\n`);
2249
2372
  process.exit(1);
2250
2373
  }
2251
- console.log(green('done'));
2374
+ if (!flags.json) console.log(green('done'));
2252
2375
 
2253
2376
  if (flags.json) {
2254
2377
  console.log(JSON.stringify(run, null, 2));
@@ -2276,7 +2399,7 @@ async function cmdRedteam(flags) {
2276
2399
  const regressed = flags['fail-on-regression'] && run.regressedCount > 0;
2277
2400
  if (belowFloor) console.error(red(` ✗ Resilience ${run.resilience} is below the required ${min}.`));
2278
2401
  if (regressed) console.error(red(` ✗ ${run.regressedCount} scenario(s) regressed since the last run.`));
2279
- if (belowFloor || regressed) process.exitCode = 2;
2402
+ if (belowFloor || regressed) process.exitCode = 1;
2280
2403
  }
2281
2404
 
2282
2405
  // ── adversary campaigns: autonomous multi-turn red-team operator ──────────
@@ -2290,19 +2413,18 @@ async function cmdRedteam(flags) {
2290
2413
  // adapting each turn to how the guard and the assistant responded. A breach
2291
2414
  // needs the whole chain to fail — the guard allows the turn AND the assistant
2292
2415
  // complies — which single-prompt scans can't surface. Needs AI configured.
2293
- // Exit: 0 = pass, 2 = below the resilience floor.
2416
+ // Exit: 0 = pass, 1 = below the resilience floor.
2294
2417
 
2295
2418
  async function cmdCampaign(flags) {
2296
2419
  const cfg = loadConfig();
2297
2420
  const { apiKey, url } = resolveSettings(cfg);
2298
2421
  if (!apiKey) {
2299
- console.error('\n' + red('✗') + ' Not configured. Run ' + bold('shomra init --key shm_live_…') + ' first.');
2300
- process.exit(1);
2422
+ exitNotConfigured();
2301
2423
  }
2302
2424
  const objectiveKeys = typeof flags.objectives === 'string' ? flags.objectives.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
2303
2425
  const turns = flags.turns != null ? parseInt(flags.turns, 10) : undefined;
2304
2426
 
2305
- process.stdout.write(dim('\n Running an autonomous adversary campaign against your assistant… '));
2427
+ if (!flags.json) process.stdout.write(dim('\n Running an autonomous adversary campaign against your assistant… '));
2306
2428
  let run;
2307
2429
  try {
2308
2430
  run = await api(url, apiKey, '/redteam/agent-campaign', {
@@ -2312,11 +2434,11 @@ async function cmdCampaign(flags) {
2312
2434
  actor: `${os.hostname()}/${os.userInfo().username}`,
2313
2435
  });
2314
2436
  } catch (e) {
2315
- console.log(red('failed'));
2437
+ if (!flags.json) console.log(red('failed'));
2316
2438
  console.error(` ${red('✗')} ${e.message}\n`);
2317
2439
  process.exit(1);
2318
2440
  }
2319
- console.log(green('done'));
2441
+ if (!flags.json) console.log(green('done'));
2320
2442
 
2321
2443
  if (flags.json) {
2322
2444
  console.log(JSON.stringify(run, null, 2));
@@ -2343,7 +2465,7 @@ async function cmdCampaign(flags) {
2343
2465
  const min = flags.min != null ? parseInt(flags.min, 10) : null;
2344
2466
  if (Number.isFinite(min) && run.resilience < min) {
2345
2467
  console.error(red(` ✗ Resilience ${run.resilience} is below the required ${min}.`));
2346
- process.exitCode = 2;
2468
+ process.exitCode = 1;
2347
2469
  }
2348
2470
  }
2349
2471
 
@@ -2359,14 +2481,13 @@ async function cmdHarden(flags) {
2359
2481
  const cfg = loadConfig();
2360
2482
  const { apiKey, url } = resolveSettings(cfg);
2361
2483
  if (!apiKey) {
2362
- console.error('\n' + red('✗') + ' Not configured. Run ' + bold('shomra init --key shm_live_…') + ' first.');
2363
- process.exit(1);
2484
+ exitNotConfigured();
2364
2485
  }
2365
2486
  const targetKind = flags.target === 'model' ? 'model' : 'llm-guard';
2366
2487
  const apply = !!flags.apply;
2367
2488
  const runId = flags.run ? String(flags.run) : undefined;
2368
2489
 
2369
- process.stdout.write(
2490
+ if (!flags.json) process.stdout.write(
2370
2491
  dim(`\n ${runId ? 'Hardening from run ' + runId : 'Red-teaming your ' + (targetKind === 'model' ? 'model' : 'LLM Guard') + ', then hardening'}… `),
2371
2492
  );
2372
2493
  let res;
@@ -2378,11 +2499,11 @@ async function cmdHarden(flags) {
2378
2499
  actor: `${os.hostname()}/${os.userInfo().username}`,
2379
2500
  });
2380
2501
  } catch (e) {
2381
- console.log(red('failed'));
2502
+ if (!flags.json) console.log(red('failed'));
2382
2503
  console.error(` ${red('✗')} ${e.message}\n`);
2383
2504
  process.exit(1);
2384
2505
  }
2385
- console.log(green('done'));
2506
+ if (!flags.json) console.log(green('done'));
2386
2507
 
2387
2508
  if (flags.json) {
2388
2509
  console.log(JSON.stringify(res, null, 2));
@@ -2423,13 +2544,12 @@ async function cmdAgentIdentity(flags, positional) {
2423
2544
  const cfg = loadConfig();
2424
2545
  const { apiKey, url } = resolveSettings(cfg);
2425
2546
  if (!apiKey) {
2426
- console.error('\n' + red('✗') + ' Not configured. Run ' + bold('shomra init --key shm_live_…') + ' first.');
2427
- process.exit(1);
2547
+ exitNotConfigured();
2428
2548
  }
2429
2549
  if (sub !== 'register') {
2430
2550
  console.error(`\n ${red('✗')} Unknown subcommand "${sub}". Use: ${bold('shomra agent-identity register --name "…" --type coding-agent')}`);
2431
2551
  console.error(dim(' (List / govern / revoke identities in the dashboard → Agent Identities.)\n'));
2432
- process.exit(1);
2552
+ process.exit(EXIT_USAGE);
2433
2553
  }
2434
2554
  let res;
2435
2555
  try {
@@ -2510,7 +2630,57 @@ const AGENT_KEYS = Object.keys(AGENT_LABELS);
2510
2630
 
2511
2631
  // The proxy base Aider (and any OpenAI-API client) should point at so its model
2512
2632
  // traffic is screened by the Shomra LLM Guard. Overridable for a remote proxy.
2513
- const LLM_PROXY_BASE = process.env.SHOMRA_LLM_PROXY_BASE || 'http://localhost:4141/llm/openai';
2633
+ // Must match cmdLlmProxy's own route shape (/openai/v1 — see its startup banner);
2634
+ // the proxy also strips a legacy `/llm` prefix so old configs keep working.
2635
+ const LLM_PROXY_BASE = process.env.SHOMRA_LLM_PROXY_BASE || 'http://127.0.0.1:4141/openai/v1';
2636
+
2637
+ // Absolute hook invocation: `"<node>" "<shomra.mjs>" tool-guard …`. A bare
2638
+ // `shomra tool-guard` breaks the moment the CLI was run via npx or PATH drifts —
2639
+ // and a hook that silently stops firing is a firewall that's off. Paths with
2640
+ // spaces (C:\Program Files\…) are quoted for both sh and cmd.
2641
+ const SELF_PATH = fileURLToPath(import.meta.url);
2642
+ function quoteArg(s) {
2643
+ return /\s/.test(s) ? `"${s}"` : s;
2644
+ }
2645
+ function hookCommand(args) {
2646
+ return `${quoteArg(process.execPath)} ${quoteArg(SELF_PATH)} ${args}`;
2647
+ }
2648
+ // Matches BOTH the legacy bare `shomra tool-guard` form and the absolute
2649
+ // `…shomra.mjs" tool-guard` form, so detection/idempotency survive the migration.
2650
+ function shomraHookRe(verb) {
2651
+ return new RegExp(`shomra(\\.mjs"?)?\\s+${verb}`, 'i');
2652
+ }
2653
+ const SHOMRA_ANY_HOOK_RE = /shomra(\.mjs"?)?\s+(tool-guard|result-guard)/i;
2654
+
2655
+ // Where each agent's hook config lives — [machine-wide, project] — the same
2656
+ // paths AGENT_INSTALLERS writes. Used by `status` for per-agent detection.
2657
+ function agentHookFiles(agent) {
2658
+ const home = os.homedir();
2659
+ const cwd = process.cwd();
2660
+ switch (agent) {
2661
+ case 'claude': return [path.join(home, '.claude', 'settings.json'), path.join(cwd, '.claude', 'settings.json')];
2662
+ case 'codex': return [path.join(home, '.codex', 'hooks.json'), path.join(cwd, '.codex', 'hooks.json')];
2663
+ case 'gemini': return [path.join(home, '.gemini', 'settings.json'), path.join(cwd, '.gemini', 'settings.json')];
2664
+ case 'cursor': return [path.join(home, '.cursor', 'hooks.json'), path.join(cwd, '.cursor', 'hooks.json')];
2665
+ case 'windsurf': return [path.join(home, '.codeium', 'windsurf', 'hooks.json'), path.join(cwd, '.windsurf', 'hooks.json')];
2666
+ case 'copilot': return [path.join(home, '.copilot', 'hooks', 'shomra.json'), path.join(cwd, '.github', 'hooks', 'shomra.json')];
2667
+ case 'cline': return [path.join(home, '.cline', 'hooks.json'), path.join(cwd, '.cline', 'hooks.json')];
2668
+ case 'aider': return [path.join(home, '.aider.conf.yml'), path.join(cwd, '.aider.conf.yml')];
2669
+ default: return [];
2670
+ }
2671
+ }
2672
+ // The config files (of the agent's own paths) that carry a Shomra hook.
2673
+ function agentHookInstalled(agent) {
2674
+ return agentHookFiles(agent).filter((f) => {
2675
+ try {
2676
+ const text = fs.readFileSync(f, 'utf8');
2677
+ if (agent === 'aider') return /shomra llm guard/i.test(text);
2678
+ return SHOMRA_ANY_HOOK_RE.test(text);
2679
+ } catch {
2680
+ return false;
2681
+ }
2682
+ });
2683
+ }
2514
2684
 
2515
2685
  function readJsonFile(file) {
2516
2686
  if (!fs.existsSync(file)) return {};
@@ -2518,16 +2688,18 @@ function readJsonFile(file) {
2518
2688
  return JSON.parse(fs.readFileSync(file, 'utf8'));
2519
2689
  } catch {
2520
2690
  console.error(red('✗') + ` ${file} is not valid JSON — fix or move it first.`);
2521
- process.exit(1);
2691
+ process.exit(EXIT_USAGE);
2522
2692
  }
2523
2693
  }
2524
2694
  // Dedupe check for the {matcher, hooks:[{command}]} grouped shape (Claude/Codex/Gemini).
2525
- function hasGroupedHook(list, needle) {
2526
- return Array.isArray(list) && list.some((g) => Array.isArray(g.hooks) && g.hooks.some((h) => String(h.command || '').includes(needle)));
2695
+ // `verb` is 'tool-guard' | 'result-guard'; matches bare AND absolute command forms.
2696
+ function hasGroupedHook(list, verb) {
2697
+ const re = shomraHookRe(verb);
2698
+ return Array.isArray(list) && list.some((g) => Array.isArray(g.hooks) && g.hooks.some((h) => re.test(String(h.command || ''))));
2527
2699
  }
2528
2700
  // Dedupe check for the flat {command} array shape (Cursor/Windsurf).
2529
2701
  function hasFlatHook(list) {
2530
- return Array.isArray(list) && list.some((h) => String(h.command || '').includes('shomra '));
2702
+ return Array.isArray(list) && list.some((h) => SHOMRA_ANY_HOOK_RE.test(String(h.command || '')));
2531
2703
  }
2532
2704
 
2533
2705
  // Each installer merges Shomra's hook(s) into that agent's config file and
@@ -2542,12 +2714,12 @@ const AGENT_INSTALLERS = {
2542
2714
  const pre = (settings.hooks.PreToolUse = settings.hooks.PreToolUse || []);
2543
2715
  const post = (settings.hooks.PostToolUse = settings.hooks.PostToolUse || []);
2544
2716
  let changed = false;
2545
- if (!hasGroupedHook(pre, 'shomra tool-guard')) {
2546
- pre.push({ matcher: 'Bash|Write|Edit|MultiEdit|NotebookEdit|mcp__.*', hooks: [{ type: 'command', command: 'shomra tool-guard --agent claude' }] });
2717
+ if (!hasGroupedHook(pre, 'tool-guard')) {
2718
+ pre.push({ matcher: 'Bash|Write|Edit|MultiEdit|NotebookEdit|mcp__.*', hooks: [{ type: 'command', command: hookCommand('tool-guard --agent claude') }] });
2547
2719
  changed = true;
2548
2720
  }
2549
- if (!hasGroupedHook(post, 'shomra result-guard')) {
2550
- post.push({ matcher: 'WebFetch|WebSearch|Read|NotebookRead|mcp__.*', hooks: [{ type: 'command', command: 'shomra result-guard --agent claude' }] });
2721
+ if (!hasGroupedHook(post, 'result-guard')) {
2722
+ post.push({ matcher: 'WebFetch|WebSearch|Read|NotebookRead|mcp__.*', hooks: [{ type: 'command', command: hookCommand('result-guard --agent claude') }] });
2551
2723
  changed = true;
2552
2724
  }
2553
2725
  if (changed) {
@@ -2565,12 +2737,12 @@ const AGENT_INSTALLERS = {
2565
2737
  const pre = (settings.PreToolUse = settings.PreToolUse || []);
2566
2738
  const post = (settings.PostToolUse = settings.PostToolUse || []);
2567
2739
  let changed = false;
2568
- if (!hasGroupedHook(pre, 'shomra tool-guard')) {
2569
- pre.push({ matcher: 'Bash|Write|Edit|mcp__.*', hooks: [{ type: 'command', command: 'shomra tool-guard --agent codex' }] });
2740
+ if (!hasGroupedHook(pre, 'tool-guard')) {
2741
+ pre.push({ matcher: 'Bash|Write|Edit|mcp__.*', hooks: [{ type: 'command', command: hookCommand('tool-guard --agent codex') }] });
2570
2742
  changed = true;
2571
2743
  }
2572
- if (!hasGroupedHook(post, 'shomra result-guard')) {
2573
- post.push({ matcher: 'WebFetch|WebSearch|Read|mcp__.*', hooks: [{ type: 'command', command: 'shomra result-guard --agent codex' }] });
2744
+ if (!hasGroupedHook(post, 'result-guard')) {
2745
+ post.push({ matcher: 'WebFetch|WebSearch|Read|mcp__.*', hooks: [{ type: 'command', command: hookCommand('result-guard --agent codex') }] });
2574
2746
  changed = true;
2575
2747
  }
2576
2748
  if (changed) {
@@ -2589,12 +2761,12 @@ const AGENT_INSTALLERS = {
2589
2761
  const before = (settings.hooks.BeforeTool = settings.hooks.BeforeTool || []);
2590
2762
  const after = (settings.hooks.AfterTool = settings.hooks.AfterTool || []);
2591
2763
  let changed = false;
2592
- if (!hasGroupedHook(before, 'shomra tool-guard')) {
2593
- before.push({ matcher: '.*', hooks: [{ type: 'command', command: 'shomra tool-guard --agent gemini' }] });
2764
+ if (!hasGroupedHook(before, 'tool-guard')) {
2765
+ before.push({ matcher: '.*', hooks: [{ type: 'command', command: hookCommand('tool-guard --agent gemini') }] });
2594
2766
  changed = true;
2595
2767
  }
2596
- if (!hasGroupedHook(after, 'shomra result-guard')) {
2597
- after.push({ matcher: '.*', hooks: [{ type: 'command', command: 'shomra result-guard --agent gemini' }] });
2768
+ if (!hasGroupedHook(after, 'result-guard')) {
2769
+ after.push({ matcher: '.*', hooks: [{ type: 'command', command: hookCommand('result-guard --agent gemini') }] });
2598
2770
  changed = true;
2599
2771
  }
2600
2772
  if (changed) {
@@ -2620,10 +2792,10 @@ const AGENT_INSTALLERS = {
2620
2792
  changed = true;
2621
2793
  }
2622
2794
  };
2623
- wire('beforeShellExecution', 'shomra tool-guard --agent cursor');
2624
- wire('beforeMCPExecution', 'shomra tool-guard --agent cursor');
2625
- wire('afterFileEdit', 'shomra result-guard --agent cursor');
2626
- wire('afterMCPExecution', 'shomra result-guard --agent cursor');
2795
+ wire('beforeShellExecution', hookCommand('tool-guard --agent cursor'));
2796
+ wire('beforeMCPExecution', hookCommand('tool-guard --agent cursor'));
2797
+ wire('afterFileEdit', hookCommand('result-guard --agent cursor'));
2798
+ wire('afterMCPExecution', hookCommand('result-guard --agent cursor'));
2627
2799
  if (changed) {
2628
2800
  fs.mkdirSync(dir, { recursive: true });
2629
2801
  fs.writeFileSync(file, JSON.stringify(cfg, null, 2));
@@ -2646,10 +2818,10 @@ const AGENT_INSTALLERS = {
2646
2818
  changed = true;
2647
2819
  }
2648
2820
  };
2649
- wire('pre_run_command', 'shomra tool-guard --agent windsurf');
2650
- wire('pre_write_code', 'shomra tool-guard --agent windsurf');
2651
- wire('pre_mcp_tool_use', 'shomra tool-guard --agent windsurf');
2652
- wire('post_mcp_tool_use', 'shomra result-guard --agent windsurf');
2821
+ wire('pre_run_command', hookCommand('tool-guard --agent windsurf'));
2822
+ wire('pre_write_code', hookCommand('tool-guard --agent windsurf'));
2823
+ wire('pre_mcp_tool_use', hookCommand('tool-guard --agent windsurf'));
2824
+ wire('post_mcp_tool_use', hookCommand('result-guard --agent windsurf'));
2653
2825
  if (changed) {
2654
2826
  fs.mkdirSync(dir, { recursive: true });
2655
2827
  fs.writeFileSync(file, JSON.stringify(cfg, null, 2));
@@ -2664,8 +2836,8 @@ const AGENT_INSTALLERS = {
2664
2836
  const file = path.join(dir, 'shomra.json');
2665
2837
  if (fs.existsSync(file)) return { file, changed: false };
2666
2838
  const cfg = {
2667
- preToolUse: [{ command: 'shomra tool-guard --agent copilot' }],
2668
- postToolUse: [{ command: 'shomra result-guard --agent copilot' }],
2839
+ preToolUse: [{ command: hookCommand('tool-guard --agent copilot') }],
2840
+ postToolUse: [{ command: hookCommand('result-guard --agent copilot') }],
2669
2841
  };
2670
2842
  fs.mkdirSync(dir, { recursive: true });
2671
2843
  fs.writeFileSync(file, JSON.stringify(cfg, null, 2));
@@ -2684,12 +2856,12 @@ const AGENT_INSTALLERS = {
2684
2856
  const pre = (settings.hooks.PreToolUse = settings.hooks.PreToolUse || []);
2685
2857
  const post = (settings.hooks.PostToolUse = settings.hooks.PostToolUse || []);
2686
2858
  let changed = false;
2687
- if (!hasGroupedHook(pre, 'shomra tool-guard')) {
2688
- pre.push({ matcher: 'execute_command|write_to_file|replace_in_file|new_rule|use_mcp_tool', hooks: [{ type: 'command', command: 'shomra tool-guard --agent cline' }] });
2859
+ if (!hasGroupedHook(pre, 'tool-guard')) {
2860
+ pre.push({ matcher: 'execute_command|write_to_file|replace_in_file|new_rule|use_mcp_tool', hooks: [{ type: 'command', command: hookCommand('tool-guard --agent cline') }] });
2689
2861
  changed = true;
2690
2862
  }
2691
- if (!hasGroupedHook(post, 'shomra result-guard')) {
2692
- post.push({ matcher: 'read_file|web_fetch|use_mcp_tool', hooks: [{ type: 'command', command: 'shomra result-guard --agent cline' }] });
2863
+ if (!hasGroupedHook(post, 'result-guard')) {
2864
+ post.push({ matcher: 'read_file|web_fetch|use_mcp_tool', hooks: [{ type: 'command', command: hookCommand('result-guard --agent cline') }] });
2693
2865
  changed = true;
2694
2866
  }
2695
2867
  if (changed) {
@@ -3286,9 +3458,12 @@ function cmdInstallHook(flags) {
3286
3458
  const unknown = requested.filter((a) => a !== 'all' && !AGENT_KEYS.includes(a));
3287
3459
  if (unknown.length) {
3288
3460
  console.error(red('✗') + ` Unknown agent(s): ${unknown.join(', ')}. Supported: ${AGENT_KEYS.join(', ')}, all.`);
3289
- process.exit(1);
3461
+ process.exit(EXIT_USAGE);
3290
3462
  }
3291
3463
  const targets = requested.includes('all') ? AGENT_KEYS : requested;
3464
+ if (!flags.agent) {
3465
+ console.log(dim(' No --agent given — installing for Claude Code only. Use ') + bold('--agent all') + dim(' (or ') + bold('shomra protect') + dim(') to cover every agent.'));
3466
+ }
3292
3467
 
3293
3468
  for (const agent of targets) {
3294
3469
  const { file, changed } = AGENT_INSTALLERS[agent](global);
@@ -3479,14 +3654,14 @@ function cmdNew(flags, positional) {
3479
3654
  const tmpl = NEW_TEMPLATES[kind];
3480
3655
  if (!tmpl) {
3481
3656
  console.error(red('✗') + ` Usage: ${bold('shomra new ' + Object.keys(NEW_TEMPLATES).join('|') + ' [name]')}`);
3482
- process.exit(1);
3657
+ process.exit(EXIT_USAGE);
3483
3658
  }
3484
3659
  const name = (positional[1] || (kind === 'rules' ? 'rules' : `my-${kind}`)).replace(/[^a-zA-Z0-9._-]/g, '-');
3485
3660
  const { file, content } = tmpl(name);
3486
3661
  const target = path.resolve(file);
3487
3662
  if (fs.existsSync(target) && !flags.force) {
3488
3663
  console.error(red('✗') + ` ${file} already exists. Use ${bold('--force')} to overwrite.`);
3489
- process.exit(1);
3664
+ process.exit(EXIT_USAGE);
3490
3665
  }
3491
3666
  fs.mkdirSync(path.dirname(target), { recursive: true });
3492
3667
  fs.writeFileSync(target, content);
@@ -3653,17 +3828,17 @@ async function cmdMcp(flags, positional) {
3653
3828
 
3654
3829
  if (sub !== 'add') {
3655
3830
  console.error(red('✗') + ` Usage: ${bold('shomra mcp add <name> <command…> | --url <url>')} ${dim('|')} ${bold('shomra mcp list')}`);
3656
- process.exit(1);
3831
+ process.exit(EXIT_USAGE);
3657
3832
  }
3658
3833
 
3659
3834
  const name = positional[1];
3660
- if (!name) { console.error(red('✗') + ` Usage: ${bold('shomra mcp add <name> <command…>')}`); process.exit(1); }
3835
+ if (!name) { console.error(red('✗') + ` Usage: ${bold('shomra mcp add <name> <command…>')}`); process.exit(EXIT_USAGE); }
3661
3836
  const server = {};
3662
3837
  if (flags.url) server.url = String(flags.url);
3663
3838
  const cmdTokens = flags.command ? String(flags.command).split(/\s+/) : positional.slice(2);
3664
3839
  if (cmdTokens.length) { server.command = cmdTokens[0]; if (cmdTokens.length > 1) server.args = cmdTokens.slice(1); }
3665
3840
  if (flags.env) server.env = parseEnvKV(flags.env);
3666
- if (!server.url && !server.command) { console.error(red('✗') + ' Provide a launch command or --url.'); process.exit(1); }
3841
+ if (!server.url && !server.command) { console.error(red('✗') + ' Provide a launch command or --url.'); process.exit(EXIT_USAGE); }
3667
3842
 
3668
3843
  // Vet the candidate BEFORE writing it anywhere: (1) local heuristics, then
3669
3844
  // (2) the platform's pre-scanned MCP Security Index (GET /catalog/lookup) so a
@@ -3675,11 +3850,18 @@ async function cmdMcp(flags, positional) {
3675
3850
 
3676
3851
  let index = null;
3677
3852
  if (!flags['no-index']) {
3678
- try {
3679
- const { url } = resolveSettings(loadConfig());
3680
- index = await mcpLookup(url, mcpLookupId(server, name));
3681
- } catch (e) {
3682
- index = { error: e.message };
3853
+ const { url } = resolveSettings(loadConfig());
3854
+ if (!url) {
3855
+ // No backend → no index to ask. Skip the fetch entirely (fetch("null/…")
3856
+ // used to surface a raw JS parse error here); the print section renders
3857
+ // this as a clean one-line "unavailable" notice.
3858
+ index = { error: 'no backend configured — set SHOMRA_URL or run shomra init --url' };
3859
+ } else {
3860
+ try {
3861
+ index = await mcpLookup(url, mcpLookupId(server, name));
3862
+ } catch (e) {
3863
+ index = { error: e.message };
3864
+ }
3683
3865
  }
3684
3866
  }
3685
3867
  const idxAlert = mcpIndexAlert(index);
@@ -3709,7 +3891,7 @@ async function cmdMcp(flags, positional) {
3709
3891
  }
3710
3892
 
3711
3893
  let cfg = {};
3712
- if (fs.existsSync(configFile)) { try { cfg = JSON.parse(fs.readFileSync(configFile, 'utf8')); } catch { console.error(red('✗') + ` ${configFile} is not valid JSON.`); process.exit(1); } }
3894
+ if (fs.existsSync(configFile)) { try { cfg = JSON.parse(fs.readFileSync(configFile, 'utf8')); } catch { console.error(red('✗') + ` ${configFile} is not valid JSON.`); process.exit(EXIT_USAGE); } }
3713
3895
  cfg.mcpServers = cfg.mcpServers || {};
3714
3896
  const existed = !!cfg.mcpServers[name];
3715
3897
  cfg.mcpServers[name] = server;
@@ -4017,8 +4199,20 @@ async function cmdModels(flags, positional) {
4017
4199
  printAlternatives(m.alternatives, 'model');
4018
4200
  if (m.notIndexed && m.source !== 'ollama') console.log(` ${dim('→ scan it now:')} ${bold('shomra model-scan ' + m.id)}`);
4019
4201
  }
4202
+ // A failed lookup is NOT a clean model — never claim "no known-vulnerable
4203
+ // models" when we could not actually check some of them.
4204
+ const failedLookups = models.filter((m) => m.error).length;
4205
+ const unchecked = failedLookups
4206
+ ? yellow(`⚠ ${failedLookups} model reference(s) could not be checked`) + dim(url ? ' (model index unreachable)' : ' (no backend configured — set SHOMRA_URL)')
4207
+ : null;
4020
4208
  console.log(
4021
- '\n ' + (blocked ? red(`✗ ${blocked} vulnerable`) + dim(` · ${flagged} to review`) : flagged ? yellow(`⚠ ${flagged} to review`) : green('✓ No known-vulnerable models')) + '\n',
4209
+ '\n ' +
4210
+ (blocked
4211
+ ? red(`✗ ${blocked} vulnerable`) + dim(` · ${flagged} to review`) + (unchecked ? ' · ' + unchecked : '')
4212
+ : flagged
4213
+ ? yellow(`⚠ ${flagged} to review`) + (unchecked ? ' · ' + unchecked : '')
4214
+ : unchecked || green('✓ No known-vulnerable models')) +
4215
+ '\n',
4022
4216
  );
4023
4217
  }
4024
4218
 
@@ -4028,7 +4222,7 @@ async function cmdModels(flags, positional) {
4028
4222
 
4029
4223
  function cmdHelp() {
4030
4224
  console.log(`
4031
- ${bold(cyan('Shomra'))} ${dim('— the firewall for AI agents · v' + VERSION)}
4225
+ ${bold(cyan('Shomra'))} ${dim('— adversarial assurance for AI agents · v' + VERSION)}
4032
4226
 
4033
4227
  ${bold('USAGE')}
4034
4228
  shomra <command> [options]
@@ -4047,6 +4241,7 @@ ${bold('COMMANDS')}
4047
4241
  ${cyan('why')} Explain a finding + false-positive read ${dim('<file> [--kind …] [--json]')}
4048
4242
  ${cyan('gate')} Vet ONE AI artifact before install ${dim('<file> [--kind …] [--strict] [--json] · --all for a whole repo (CI)')}
4049
4243
  ${cyan('scan')} Discover AI tooling on this machine ${dim('[--report] [--json] [--path <dir>]')}
4244
+ ${cyan('report')} Discover + send inventory to your Shomra org ${dim('(alias: scan --report) [--json]')}
4050
4245
  ${cyan('status')} Show config, enrollment + firewall health
4051
4246
 
4052
4247
  ${dim('Setup — run once per machine / repo')}
@@ -4066,6 +4261,7 @@ ${bold('COMMANDS')}
4066
4261
  ${dim('Build safely')}
4067
4262
  ${cyan('new')} Scaffold a secure-by-default artifact ${dim('skill|command|subagent|agent-card|mcp|rules [name]')}
4068
4263
  ${cyan('mcp add')} Vet an MCP server, then add it to a config ${dim('<name> <command…>|--url <url> [--config <f>] [--force]')}
4264
+ ${cyan('mcp list')} List the MCP servers in a config ${dim('[--config <f>] [--json]')}
4069
4265
  ${cyan('mcp serve')} Run Shomra AS an MCP server so agents call its checks ${dim('(check/scan_models/fix/explain tools)')}
4070
4266
 
4071
4267
  ${dim('Governance & advanced')} ${dim('→')} ${bold('shomra admin')} ${dim('for the full list')}
@@ -4220,7 +4416,8 @@ ${bold('RUNTIME FIREWALL (multi-agent)')}
4220
4416
  Default target is Claude Code (unchanged for existing installs). Add
4221
4417
  ${bold('--agent <name>')} (comma-separated, or ${bold('all')}) to also wire in:
4222
4418
  ${dim('claude')} (Claude Code) · ${dim('cursor')} (Cursor) · ${dim('windsurf')} (Windsurf/Cascade) ·
4223
- ${dim('gemini')} (Gemini CLI) · ${dim('codex')} (OpenAI Codex CLI) · ${dim('copilot')} (GitHub Copilot CLI)
4419
+ ${dim('gemini')} (Gemini CLI) · ${dim('codex')} (OpenAI Codex CLI) · ${dim('copilot')} (GitHub Copilot CLI) ·
4420
+ ${dim('cline')} (Cline) · ${dim('aider')} (Aider — no tool hooks, so it is routed through the LLM Guard proxy)
4224
4421
  e.g. ${dim('shomra install-hook --agent cursor,windsurf')} or ${dim('shomra install-hook --agent all')}.
4225
4422
  Windsurf's post-hooks can flag/log but not withhold a result (vendor limit).
4226
4423
 
@@ -4237,11 +4434,19 @@ ${bold('RUNTIME FIREWALL (multi-agent)')}
4237
4434
  that skips a known-down backend. Fail-open by default (the local tier is still
4238
4435
  enforcing); SHOMRA_GUARD_STRICT=1 to also fail-closed on the server tier.
4239
4436
 
4437
+ ${bold('EXIT CODES')} ${dim('— one convention across every command')}
4438
+ 0 clean / pass
4439
+ 1 hard fail — BLOCK, vulnerable model, secret found, FAIL verdict, below --min, regression
4440
+ 2 soft fail — FLAG under --strict (REVIEW when strict)
4441
+ 3 usage / config error — not configured, bad flags, unknown command
4442
+
4240
4443
  ${bold('ENV')}
4241
4444
  SHOMRA_API_KEY API key (overrides config)
4242
4445
  SHOMRA_URL Backend URL (overrides config)
4243
4446
  SHOMRA_API_TIMEOUT_MS=30000 Per-request backend timeout for scan/gate/report (never hangs)
4244
4447
  SHOMRA_AGENT Agent-identity handle presented as x-shomra-agent (llm-proxy + firewall)
4448
+ SHOMRA_GATE_CONCURRENCY=8 Parallel backend gate/model-lookup calls in batch runs (1–32)
4449
+ SHOMRA_GH_TOKEN GitHub token for \`shomra pr\` (falls back to GITHUB_TOKEN)
4245
4450
  SHOMRA_GUARD_STRICT=1 Fail-closed on the server tier if the backend is unreachable
4246
4451
  SHOMRA_GUARD_LOCAL=0 Disable the on-machine Tier-0 guard (route everything to the server)
4247
4452
  SHOMRA_GUARD_IGNORE=<globs> Comma-separated file globs the runtime guard treats as known-safe (never
@@ -4250,6 +4455,10 @@ ${bold('ENV')}
4250
4455
  SHOMRA_GUARD_ALWAYS_ESCALATE=1 Send every call to the server (full telemetry, higher overhead)
4251
4456
  SHOMRA_GUARD_TIMEOUT_MS=2000 Per-call server timeout budget (default 2000)
4252
4457
  SHOMRA_GUARD_BREAKER_MS=30000 Skip the server for this long after a failure (0 disables)
4458
+ SHOMRA_LLM_PROXY_BASE Proxy base URL install-hook writes for Aider (default http://127.0.0.1:4141/openai/v1)
4459
+ SHOMRA_MODEL_GUARD=0 Disable the model-load screen in the PreToolUse hook
4460
+ SHOMRA_MODEL_CACHE=0 Disable the on-machine model-index verdict cache
4461
+ SHOMRA_MODEL_CACHE_TTL_MS Model-cache freshness window (default 7 days)
4253
4462
  `);
4254
4463
  }
4255
4464
 
@@ -4301,11 +4510,28 @@ const ADMIN_VERBS = new Set([
4301
4510
 
4302
4511
  async function main() {
4303
4512
  const [, , command, ...rest] = process.argv;
4304
- const { flags, positional } = parseFlags(rest);
4513
+ const { flags, positional, unknown } = parseFlags(rest);
4305
4514
 
4306
4515
  if (command === 'help' || command === undefined || command === '--help' || command === '-h') {
4307
4516
  return cmdHelp();
4308
4517
  }
4518
+ if (command === '--version' || command === '-v' || command === 'version') {
4519
+ console.log(VERSION); // single source: package.json (see VERSION above)
4520
+ return;
4521
+ }
4522
+
4523
+ // Unknown --flags used to silently no-op — the worst failure mode for a
4524
+ // security gate (`--strcit` = strict mode silently off). Hook handlers are
4525
+ // exempt: a vendor passing a new flag must never break every tool call.
4526
+ const guardCmd = command === 'tool-guard' || command === 'result-guard';
4527
+ if (unknown.length && !guardCmd) {
4528
+ for (const u of unknown) {
4529
+ const near = didYouMean(u, [...KNOWN_FLAGS]);
4530
+ console.error(red(`✗ Unknown flag: --${u}`) + (near ? dim(` (did you mean --${near}?)`) : ''));
4531
+ }
4532
+ console.error(dim('Run `shomra help` for the full option list.'));
4533
+ process.exit(EXIT_USAGE);
4534
+ }
4309
4535
 
4310
4536
  // `shomra admin <verb> …` — the governance namespace.
4311
4537
  if (command === 'admin') {
@@ -4313,18 +4539,20 @@ async function main() {
4313
4539
  if (!sub || sub === 'help' || flags.help) return cmdAdminHelp();
4314
4540
  const fn = COMMANDS[sub];
4315
4541
  if (!fn || !ADMIN_VERBS.has(sub)) {
4316
- console.error(red(`Unknown admin command: ${sub ?? ''}`));
4317
- cmdAdminHelp();
4318
- process.exit(1);
4542
+ const near = didYouMean(sub, [...ADMIN_VERBS]);
4543
+ console.error(red(`✗ Unknown admin command: ${sub ?? ''}`) + (near ? ` did you mean ${bold(near)}?` : ''));
4544
+ console.error(dim('Run `shomra admin` for the list.'));
4545
+ process.exit(EXIT_USAGE);
4319
4546
  }
4320
4547
  return fn(flags, positional.slice(1));
4321
4548
  }
4322
4549
 
4323
4550
  const fn = COMMANDS[command];
4324
4551
  if (!fn) {
4325
- console.error(red(`Unknown command: ${command}`));
4326
- cmdHelp();
4327
- process.exit(1);
4552
+ const near = didYouMean(command, [...Object.keys(COMMANDS), 'help', 'version', 'admin']);
4553
+ console.error(red(`✗ Unknown command: ${command}`) + (near ? ` did you mean ${bold(near)}?` : ''));
4554
+ console.error(dim('Run `shomra help` for the full command list.'));
4555
+ process.exit(EXIT_USAGE);
4328
4556
  }
4329
4557
  return fn(flags, positional);
4330
4558
  }