@shomra/agent 0.2.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.
package/shomra.mjs ADDED
@@ -0,0 +1,4193 @@
1
+ #!/usr/bin/env node
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.
7
+ *
8
+ * shomra init --key shm_live_… --url <your backend> # connect to a Shomra org (optional)
9
+ * shomra scan # discover + analyze, print a local report
10
+ * shomra report # discover + send to the platform (alias: scan --report)
11
+ * shomra status # show config + enrollment
12
+ */
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import os from 'node:os';
16
+ import crypto from 'node:crypto';
17
+ import { execSync } from 'node:child_process';
18
+ import { discoverAll } from './discovery.mjs';
19
+ import { localScan, localGate, grade, downrankCodeContext, SECRET_PATTERNS } from './guard-signals.mjs';
20
+ import { scanSourceFile, isScannableSource, isModelConfig } from './code-sast.mjs';
21
+ import { scanModelRefs, isModelRefScannable } from './model-refs.mjs';
22
+
23
+ const VERSION = '0.2.0';
24
+ const CONFIG_DIR = path.join(os.homedir(), '.shomra');
25
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
26
+
27
+ // ── tiny ANSI helpers ────────────────────────────────────────────
28
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
29
+ const c = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : String(s));
30
+ const dim = c('2'), bold = c('1'), red = c('31'), green = c('32'), yellow = c('33'), cyan = c('36'), magenta = c('35'), gray = c('90');
31
+ const SEV_COLOR = { CRITICAL: red, HIGH: red, MEDIUM: yellow, LOW: cyan, INFO: gray };
32
+ const VERDICT_COLOR = { FAIL: red, REVIEW: yellow, PASS: green };
33
+
34
+ function loadConfig() {
35
+ try {
36
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
37
+ } catch {
38
+ return {};
39
+ }
40
+ }
41
+ function saveConfig(cfg) {
42
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
43
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2));
44
+ }
45
+ function getMachineId(cfg) {
46
+ if (cfg.machineId) return cfg.machineId;
47
+ cfg.machineId = crypto.randomUUID();
48
+ saveConfig(cfg);
49
+ return cfg.machineId;
50
+ }
51
+ function resolveSettings(cfg) {
52
+ // Local-first: there is NO built-in backend URL. Shomra runs fully on-machine
53
+ // and only reaches a backend when the user has configured one — via
54
+ // SHOMRA_URL, or `shomra init --url <your backend>` (persisted to config).
55
+ // Absent that, `url` is null and every backend-only feature degrades cleanly
56
+ // to the on-machine result. (Pin "localhost" → 127.0.0.1: it resolves to ::1
57
+ // first under Node's fetch, but a backend may only answer on IPv4.)
58
+ const raw = process.env.SHOMRA_URL || cfg.url || '';
59
+ return {
60
+ apiKey: process.env.SHOMRA_API_KEY || cfg.apiKey,
61
+ url: raw ? raw.replace(/\/$/, '').replace('://localhost', '://127.0.0.1') : null,
62
+ };
63
+ }
64
+
65
+ // ── guard latency budget + circuit breaker ───────────────────────
66
+ // The PreToolUse/PostToolUse guards run on EVERY tool call in a fresh process,
67
+ // so they must be snappy and self-healing when the backend is slow or down.
68
+ // - A tight, configurable timeout caps the per-call wait (default 2s).
69
+ // - A file-based breaker remembers a recent failure across processes: once
70
+ // the backend times out/errors, the next calls fail-open INSTANTLY for a
71
+ // cooldown window instead of each independently paying the full timeout.
72
+ // In strict mode the breaker is ignored — a fail-closed operator accepts the
73
+ // latency in exchange for enforcement even while the backend is unreachable.
74
+ const BREAKER_FILE = path.join(CONFIG_DIR, 'guard-breaker.json');
75
+ function clampInt(v, def, min, max) {
76
+ const n = parseInt(v, 10);
77
+ return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : def;
78
+ }
79
+ function guardTimeoutMs() {
80
+ return clampInt(process.env.SHOMRA_GUARD_TIMEOUT_MS, 2000, 200, 30000);
81
+ }
82
+ function breakerCooldownMs() {
83
+ return clampInt(process.env.SHOMRA_GUARD_BREAKER_MS, 30000, 0, 600000);
84
+ }
85
+ function breakerOpen() {
86
+ const cooldown = breakerCooldownMs();
87
+ if (cooldown === 0) return false; // breaker disabled
88
+ try {
89
+ const { at } = JSON.parse(fs.readFileSync(BREAKER_FILE, 'utf8'));
90
+ return typeof at === 'number' && Date.now() - at < cooldown;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+ function breakerTrip() {
96
+ try {
97
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
98
+ fs.writeFileSync(BREAKER_FILE, JSON.stringify({ at: Date.now() }));
99
+ } catch {
100
+ /* best-effort — a missing breaker just means the next call retries */
101
+ }
102
+ }
103
+ function breakerReset() {
104
+ try {
105
+ fs.rmSync(BREAKER_FILE, { force: true });
106
+ } catch {
107
+ /* ignore */
108
+ }
109
+ }
110
+ // Machine identity attached to gate / guard / proxy calls so the backend can
111
+ // attribute the activity to this enrolled machine. Unlike machineInfo() it does
112
+ // NOT generate/persist a machineId — an unenrolled machine simply reports none,
113
+ // leaving the event unattributed rather than writing config from a hook.
114
+ function gateMachine() {
115
+ let machineId;
116
+ try {
117
+ machineId = loadConfig().machineId;
118
+ } catch {
119
+ /* no config — unenrolled */
120
+ }
121
+ return { ...(machineId ? { machineId } : {}), hostname: os.hostname(), username: os.userInfo().username };
122
+ }
123
+ function machineInfo(cfg) {
124
+ return {
125
+ machineId: getMachineId(cfg),
126
+ hostname: os.hostname(),
127
+ platform: process.platform,
128
+ osRelease: os.release(),
129
+ username: os.userInfo().username,
130
+ agentVersion: VERSION,
131
+ };
132
+ }
133
+
134
+ async function api(url, key, route, body, opts = {}) {
135
+ // No backend configured → make the reason explicit (callers catch this and
136
+ // fall back to the on-machine result rather than surfacing a fetch error).
137
+ if (!url) throw new Error('no backend configured — set SHOMRA_URL or run `shomra init --url <your backend>`');
138
+ // Every backend call is bounded — an unreachable or hung backend must never
139
+ // hang the CLI (which would freeze a dev's terminal or wedge a CI job).
140
+ const timeoutMs = opts.timeoutMs ?? clampInt(process.env.SHOMRA_API_TIMEOUT_MS, 30000, 1000, 600000);
141
+ const ctrl = new AbortController();
142
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
143
+ let res;
144
+ try {
145
+ res = await fetch(`${url}${route}`, {
146
+ method: 'POST',
147
+ // Connection: close avoids undici keep-alive sockets lingering after the
148
+ // command finishes (which can crash process.exit on Windows).
149
+ headers: { 'Content-Type': 'application/json', 'X-Shomra-Key': key, Connection: 'close' },
150
+ body: JSON.stringify(body),
151
+ signal: ctrl.signal,
152
+ });
153
+ } catch (e) {
154
+ if (e?.name === 'AbortError') throw new Error(`request timed out after ${timeoutMs}ms (raise SHOMRA_API_TIMEOUT_MS or check the backend)`);
155
+ throw e;
156
+ } finally {
157
+ clearTimeout(timer);
158
+ }
159
+ const text = await res.text();
160
+ let json;
161
+ try {
162
+ json = JSON.parse(text);
163
+ } catch {
164
+ json = { raw: text };
165
+ }
166
+ if (!res.ok) {
167
+ const msg = json?.message || json?.raw || res.statusText;
168
+ throw new Error(`${res.status} ${Array.isArray(msg) ? msg.join(', ') : msg}`);
169
+ }
170
+ return json;
171
+ }
172
+
173
+ function parseFlags(argv) {
174
+ const flags = {};
175
+ const positional = [];
176
+ for (let i = 0; i < argv.length; i++) {
177
+ const a = argv[i];
178
+ if (a.startsWith('--')) {
179
+ const body = a.slice(2);
180
+ // Support both `--key value` and `--key=value`.
181
+ const eq = body.indexOf('=');
182
+ if (eq !== -1) {
183
+ flags[body.slice(0, eq)] = body.slice(eq + 1);
184
+ continue;
185
+ }
186
+ const next = argv[i + 1];
187
+ if (next !== undefined && !next.startsWith('--')) {
188
+ flags[body] = next;
189
+ i++;
190
+ } else flags[body] = true;
191
+ } else positional.push(a);
192
+ }
193
+ return { flags, positional };
194
+ }
195
+
196
+ // ── commands ─────────────────────────────────────────────────────
197
+ async function cmdInit(flags) {
198
+ const cfg = loadConfig();
199
+ const key = flags.key || process.env.SHOMRA_API_KEY;
200
+ // No default backend (local-first): enrolling means pointing at a real Shomra
201
+ // org, so the URL is required when one isn't already saved.
202
+ const url = (flags.url || cfg.url || '').replace(/\/$/, '');
203
+ if (!key) {
204
+ console.error(red('✗') + ' Missing API key. Run: ' + bold('shomra init --key shm_live_… --url <your backend>'));
205
+ process.exit(1);
206
+ }
207
+ if (!url) {
208
+ console.error(red('✗') + ' Missing backend URL. Run: ' + bold('shomra init --key shm_live_… --url <your backend>'));
209
+ process.exit(1);
210
+ }
211
+ cfg.apiKey = key;
212
+ cfg.url = url;
213
+ getMachineId(cfg);
214
+ saveConfig(cfg);
215
+ process.stdout.write(dim('Enrolling this machine… '));
216
+ try {
217
+ const res = await api(url, key, '/agent/enroll', { machine: machineInfo(cfg) });
218
+ console.log(green('done'));
219
+ console.log(` ${green('✓')} Enrolled ${bold(os.hostname())} into org ${bold(res.org?.name ?? '?')}`);
220
+ console.log(` ${dim('Config saved to ' + CONFIG_FILE)}`);
221
+ console.log(`\n Next: ${bold('shomra report')} to send your first inventory.`);
222
+ } catch (e) {
223
+ console.log(red('failed'));
224
+ console.error(` ${red('✗')} ${e.message}`);
225
+ process.exit(1);
226
+ }
227
+ }
228
+
229
+ function discover(flags) {
230
+ // With --path, scan exactly that tree. Without it, auto-expand to the
231
+ // developer's real workspace (cwd + common project dirs under $HOME).
232
+ const roots = flags.path ? [path.resolve(String(flags.path))] : [process.cwd()];
233
+ return discoverAll(roots, { autoExpand: !flags.path });
234
+ }
235
+
236
+ function printAssets(assets) {
237
+ const byType = {};
238
+ for (const a of assets) byType[a.type] = (byType[a.type] ?? 0) + 1;
239
+ console.log(bold('\n Discovered AI assets'));
240
+ console.log(
241
+ ' ' +
242
+ Object.entries(byType)
243
+ .map(([t, n]) => `${cyan(n)} ${dim(t.replace('_', ' ').toLowerCase())}`)
244
+ .join(dim(' · ')),
245
+ );
246
+ for (const a of assets) {
247
+ console.log(` ${gray('•')} ${bold(a.name)} ${dim(a.type)} ${a.vendor ? gray('(' + a.vendor + ')') : ''}`);
248
+ if (a.identifier && a.identifier !== a.name) console.log(` ${dim(a.identifier)}`);
249
+ }
250
+ }
251
+
252
+ async function cmdScan(flags) {
253
+ const cfg = loadConfig();
254
+ const assets = discover(flags);
255
+ if (flags.json && !flags.report) {
256
+ console.log(JSON.stringify({ machine: machineInfo(cfg), assets }, null, 2));
257
+ return;
258
+ }
259
+ console.log(bold(cyan('\n Shomra')) + dim(` agent v${VERSION} — local scan`));
260
+ printAssets(assets);
261
+
262
+ if (flags.report) {
263
+ await sendReport(cfg, assets, flags);
264
+ } else {
265
+ console.log(
266
+ dim('\n Run ') + bold('shomra report') + dim(' to analyze these on the platform and see findings.\n'),
267
+ );
268
+ }
269
+ }
270
+
271
+ async function sendReport(cfg, assets, flags) {
272
+ const { apiKey, url } = resolveSettings(cfg);
273
+ if (!apiKey) {
274
+ console.error('\n' + red('✗') + ' Not configured. Run ' + bold('shomra init --key shm_live_…') + ' first.');
275
+ process.exit(1);
276
+ }
277
+ process.stdout.write(dim('\n Reporting to platform… '));
278
+ try {
279
+ const res = await api(url, apiKey, '/agent/report', { machine: machineInfo(cfg), assets });
280
+ console.log(green('done') + dim(` (${res.assets} assets analyzed)`));
281
+ if (flags.json) {
282
+ console.log(JSON.stringify(res, null, 2));
283
+ return;
284
+ }
285
+ console.log('');
286
+ if (Array.isArray(res.results)) {
287
+ for (const r of res.results.filter((x) => x.findingCount > 0)) {
288
+ const vc = VERDICT_COLOR[r.verdict] || gray;
289
+ console.log(` ${vc('●')} ${bold(r.name)} ${dim('risk ' + r.riskScore)} ${vc(r.verdict)}`);
290
+ for (const f of r.findings || []) {
291
+ console.log(` ${SEV_COLOR[f.severity](f.severity.padEnd(8))} ${f.title}`);
292
+ }
293
+ }
294
+ }
295
+ const crit = res.critical ?? 0;
296
+ const high = res.high ?? 0;
297
+ console.log(
298
+ '\n ' +
299
+ (crit + high > 0
300
+ ? `${red(crit + ' critical')} · ${yellow(high + ' high')} ${dim('— view & remediate at the Shomra dashboard')}`
301
+ : green('No high-severity findings. ') + dim('Nice and clean.')),
302
+ );
303
+ console.log(dim(` Endpoint: ${res.endpointId}\n`));
304
+ if (crit > 0) process.exitCode = 2;
305
+ } catch (e) {
306
+ console.log(red('failed'));
307
+ console.error(` ${red('✗')} ${e.message}\n`);
308
+ process.exit(1);
309
+ }
310
+ }
311
+
312
+ // Human name for a key scope, inferred from its prefix. Accepts the current
313
+ // `shm_` prefix and the legacy pre-rebrand `dgx_` (older keys keep working).
314
+ function keyScope(key) {
315
+ if (!key) return null;
316
+ if (/^(shm|dgx)_gw_/.test(key)) return 'gateway';
317
+ if (/^(shm|dgx)_ci_/.test(key)) return 'CI';
318
+ return 'agent';
319
+ }
320
+
321
+ function cmdStatus() {
322
+ const cfg = loadConfig();
323
+ const { apiKey, url } = resolveSettings(cfg);
324
+ const enrolled = !!apiKey;
325
+ console.log(bold(cyan('\n Shomra agent')) + dim(` v${VERSION}`));
326
+
327
+ // Mode banner — the whole point: what works right now, and what a key adds.
328
+ if (enrolled) {
329
+ console.log(` ${dim('Mode ')} ${green('● Enrolled')} ${dim(`(${keyScope(apiKey)} key)`)} — org policy, platform AI & dashboard telemetry active`);
330
+ } else {
331
+ console.log(` ${dim('Mode ')} ${cyan('● Local')} ${dim('— on-machine analysis only; nothing leaves this machine')}`);
332
+ console.log(` ${dim(' ')} ${dim('Run')} ${bold('shomra init --key shm_…')} ${dim('to add org policy, AI fixes, deep scans & the dashboard.')}`);
333
+ }
334
+ console.log(` ${dim('Backend ')} ${url}`);
335
+ console.log(` ${dim('API key ')} ${apiKey ? green(apiKey.slice(0, 14) + '…') : dim('none (local mode)')}`);
336
+ console.log(` ${dim('Machine ')} ${os.hostname()} ${dim('(' + (cfg.machineId || 'unenrolled') + ')')}`);
337
+ console.log(` ${dim('Config ')} ${CONFIG_FILE}`);
338
+
339
+ // What each tier unlocks, so the free/paid line is explicit.
340
+ console.log(bold('\n Available now') + dim(enrolled ? '' : ' (local, no key)'));
341
+ console.log(` ${green('✓')} ${dim('check · gate · doctor · protect · secrets · models · new · mcp add · why (offline)')}`);
342
+ console.log(` ${enrolled ? green('✓') : gray('○')} ${(enrolled ? dim : gray)('fix (AI) · deep scans (scan-zip/model-scan/memory-scan) · org policy · dashboard telemetry')}`);
343
+
344
+ // Runtime firewall health — is the guard wired in, and is it in a state that
345
+ // could freeze the agent? (checks Claude Code's global + project settings).
346
+ const hookFiles = [
347
+ path.join(os.homedir(), '.claude', 'settings.json'),
348
+ path.join(process.cwd(), '.claude', 'settings.json'),
349
+ ].filter((f) => {
350
+ try {
351
+ return fs.readFileSync(f, 'utf8').includes('shomra tool-guard');
352
+ } catch {
353
+ return false;
354
+ }
355
+ });
356
+ const localOff = process.env.SHOMRA_GUARD_LOCAL === '0' || String(process.env.SHOMRA_GUARD_LOCAL).toLowerCase() === 'false';
357
+ const strict = envFlag('SHOMRA_GUARD_STRICT');
358
+ console.log(bold('\n Runtime firewall'));
359
+ console.log(` ${dim('Hook ')} ${hookFiles.length ? green('installed') + dim(' → ' + hookFiles.join(', ')) : yellow('not installed') + dim(' (run: shomra install-hook)')}`);
360
+ console.log(` ${dim('Tier 0 ')} ${localOff ? yellow('off') + dim(' (server-only)') : green('on') + dim(' — dangerous calls blocked on-machine, zero network')}`);
361
+ console.log(` ${dim('Mode ')} ${strict ? 'fail-closed (strict)' : 'fail-open'}${dim(` · server timeout ${guardTimeoutMs()}ms · breaker ${breakerCooldownMs()}ms`)}`);
362
+ console.log(` ${dim('Breaker ')} ${breakerOpen() ? red('OPEN') + dim(' — backend recently unreachable; server tier is being skipped') : green('closed')}\n`);
363
+ }
364
+
365
+ // ── the Dev Gate: vet an AI artifact BEFORE installing it ────────
366
+ //
367
+ // shomra gate .mcp.json # auto-classified from the path
368
+ // shomra gate my-skill/SKILL.md --kind skill
369
+ // cat cfg.json | shomra gate --stdin --kind mcp --name github-server
370
+ //
371
+ // Exit codes: 0 = ALLOW (or FLAG), 1 = BLOCK, 2 = FLAG with --strict.
372
+ // Wire it as a pre-install / pre-commit hook so risky MCP servers, skills,
373
+ // slash commands and hooks never land on the machine unvetted.
374
+
375
+ const GATE_KINDS = ['mcp', 'skill', 'command', 'subagent', 'hook', 'rules', 'agent-card', 'memory', 'auto'];
376
+
377
+ // Detect the execution environment so the platform can split local-dev gate
378
+ // checks from CI/pipeline ones (and attribute repo/branch/commit for CISOs).
379
+ function detectEnv() {
380
+ const e = process.env;
381
+ const pick = (...keys) => {
382
+ for (const k of keys) if (e[k]?.trim()) return e[k].trim();
383
+ return undefined;
384
+ };
385
+ let ci = null;
386
+ if (e.GITHUB_ACTIONS) ci = { ciProvider: 'github-actions', repo: e.GITHUB_REPOSITORY, ref: e.GITHUB_REF_NAME, commit: e.GITHUB_SHA };
387
+ else if (e.GITLAB_CI) ci = { ciProvider: 'gitlab-ci', repo: e.CI_PROJECT_PATH, ref: e.CI_COMMIT_REF_NAME, commit: e.CI_COMMIT_SHA };
388
+ else if (e.CIRCLECI) ci = { ciProvider: 'circleci', repo: e.CIRCLE_PROJECT_REPONAME, ref: e.CIRCLE_BRANCH, commit: e.CIRCLE_SHA1 };
389
+ else if (e.TF_BUILD) ci = { ciProvider: 'azure-pipelines', repo: e.BUILD_REPOSITORY_NAME, ref: e.BUILD_SOURCEBRANCHNAME, commit: e.BUILD_SOURCEVERSION };
390
+ else if (e.BITBUCKET_BUILD_NUMBER) ci = { ciProvider: 'bitbucket-pipelines', repo: e.BITBUCKET_REPO_FULL_NAME, ref: e.BITBUCKET_BRANCH, commit: e.BITBUCKET_COMMIT };
391
+ else if (e.JENKINS_URL) ci = { ciProvider: 'jenkins', repo: pick('JOB_NAME'), ref: e.GIT_BRANCH, commit: e.GIT_COMMIT };
392
+ else if (e.CI) ci = { ciProvider: 'ci', repo: undefined, ref: undefined, commit: undefined };
393
+
394
+ if (ci) {
395
+ const git = gitContext();
396
+ return {
397
+ environment: 'CI',
398
+ ciProvider: ci.ciProvider,
399
+ repo: ci.repo ?? git.repo,
400
+ ref: ci.ref ?? git.ref,
401
+ commit: ci.commit ?? git.commit,
402
+ };
403
+ }
404
+ // Local dev — enrich with git if we're inside a repo.
405
+ return { environment: 'LOCAL', ...gitContext() };
406
+ }
407
+
408
+ // Best-effort git context via the git CLI (zero extra deps). Never throws.
409
+ function gitContext() {
410
+ const run = (args) => {
411
+ try {
412
+ return execSync(`git ${args}`, { stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 })
413
+ .toString()
414
+ .trim();
415
+ } catch {
416
+ return undefined;
417
+ }
418
+ };
419
+ const origin = run('config --get remote.origin.url');
420
+ let repo;
421
+ if (origin) {
422
+ const m = origin.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
423
+ repo = m ? m[1] : undefined;
424
+ }
425
+ return { repo, ref: run('rev-parse --abbrev-ref HEAD'), commit: run('rev-parse HEAD') };
426
+ }
427
+
428
+ // Shape a localGate() result into the same object the backend /gate/check
429
+ // returns, so the printer/exit logic treats local and server results uniformly.
430
+ function localAsGateResult(local, name, kind) {
431
+ return {
432
+ decision: local.verdict,
433
+ name: name || 'artifact',
434
+ kind: kind || 'auto',
435
+ riskScore: local.riskScore,
436
+ findingCount: local.findings.length,
437
+ findings: local.findings.map((f) => ({ severity: f.severity, title: f.title, remediationText: f.remediationText, ...(f.line ? { line: f.line } : {}) })),
438
+ };
439
+ }
440
+
441
+ function printGateResult(res, source, flags) {
442
+ if (flags.json) {
443
+ console.log(JSON.stringify({ source, ...res }, null, 2));
444
+ return;
445
+ }
446
+ const dc = res.decision === 'BLOCK' ? red : res.decision === 'FLAG' ? yellow : green;
447
+ console.log(dc(res.decision) + (source === 'local' ? dim(' (on-machine)') : ''));
448
+ console.log(`\n ${dc('●')} ${bold(res.name)} ${dim(`${res.kind} · risk ${res.riskScore}/100 · ${res.findingCount ?? (res.findings || []).length} finding(s)`)}`);
449
+ for (const f of res.findings || []) {
450
+ console.log(` ${SEV_COLOR[f.severity](String(f.severity).padEnd(8))} ${f.title}`);
451
+ if (f.remediationText) console.log(` ${dim('fix: ' + f.remediationText)}`);
452
+ }
453
+ for (const c of res.catalog || []) {
454
+ const vc = VERDICT_COLOR[c.verdict] || gray;
455
+ console.log(` ${dim('catalog:')} ${c.name} ${vc(c.verdict ?? 'UNSCANNED')} ${dim('risk ' + c.riskScore)}`);
456
+ }
457
+ for (const p of res.policyHits || []) {
458
+ // A policy hit the org triaged away (accepted-risk / ignored) is recorded but
459
+ // did not drive the decision — show it struck-through-in-words so it's clear WHY
460
+ // a CRITICAL didn't block.
461
+ const note = p.suppressed ? dim(' (not enforced — accepted risk / ignored in your org)') : '';
462
+ console.log(` ${dim('policy:')} ${p.policy} ${dim('→')} ${p.action}${note}`);
463
+ }
464
+ const triaged = (res.policyHits || []).filter((p) => p.suppressed).length;
465
+ if (triaged) console.log(` ${dim(`${triaged} policy hit(s) suppressed by triage — reopen or let the acceptance expire to re-enforce.`)}`);
466
+ const orgNote = source === 'local' ? dim(' (on-machine analysis; org policy not applied)') : '';
467
+ if (res.decision === 'BLOCK') console.log(`\n ${red('✗ Blocked.')}${orgNote} ${dim('Review the findings above.')}\n`);
468
+ else if (res.decision === 'FLAG') console.log(`\n ${yellow('⚠ Flagged.')}${orgNote} ${dim('Proceed with caution.')}\n`);
469
+ else console.log(`\n ${green('✓ Allowed.')}${orgNote} ${dim('No high-risk findings.')}\n`);
470
+ }
471
+
472
+ async function cmdGate(flags, positional) {
473
+ const cfg = loadConfig();
474
+ const { apiKey, url } = resolveSettings(cfg);
475
+
476
+ // Batch mode: gate every AI artifact under a directory (the CI story).
477
+ if (flags.all) {
478
+ return cmdGateAll(flags, positional, { apiKey, url });
479
+ }
480
+
481
+ const file = positional[0];
482
+ let content;
483
+ let relPath;
484
+ let fullTarget = null;
485
+ if (flags.stdin) {
486
+ content = fs.readFileSync(0, 'utf8');
487
+ relPath = flags.path || null;
488
+ } else {
489
+ if (!file) {
490
+ console.error(red('✗') + ' Usage: ' + bold('shomra gate <file> [--kind mcp|skill|command|subagent|hook|rules|agent-card|memory] [--name x] [--strict] [--json]'));
491
+ process.exit(1);
492
+ }
493
+ let target = path.resolve(String(file));
494
+ // A directory gates its SKILL.md (the skill-install case).
495
+ if (fs.existsSync(target) && fs.statSync(target).isDirectory()) {
496
+ const skillMd = path.join(target, 'SKILL.md');
497
+ if (!fs.existsSync(skillMd)) {
498
+ console.error(red('✗') + ` ${file} is a directory with no SKILL.md — point at a file instead.`);
499
+ process.exit(1);
500
+ }
501
+ target = skillMd;
502
+ }
503
+ if (!fs.existsSync(target)) {
504
+ console.error(red('✗') + ` File not found: ${file}`);
505
+ process.exit(1);
506
+ }
507
+ content = fs.readFileSync(target, 'utf8');
508
+ relPath = path.relative(process.cwd(), target).split(path.sep).join('/');
509
+ fullTarget = target;
510
+ }
511
+
512
+ const kind = flags.kind && GATE_KINDS.includes(String(flags.kind)) ? String(flags.kind) : undefined;
513
+ const name = flags.name ? String(flags.name) : (relPath ? relPath.split('/').pop() : 'artifact');
514
+
515
+ // ── Local analysis ALWAYS runs — a real verdict with no backend needed ──
516
+ const local = localGate(content, { kind, path: relPath });
517
+
518
+ // ── Enrich with the backend (org policy + governance) when reachable ──
519
+ let res = null;
520
+ let source = 'local';
521
+ if (apiKey) {
522
+ if (!flags.json) process.stdout.write(dim(' Checking with Shomra gate… '));
523
+ try {
524
+ res = await api(url, apiKey, '/gate/check', {
525
+ ...(kind ? { kind } : {}),
526
+ ...(flags.name ? { name: String(flags.name) } : {}),
527
+ ...(relPath ? { path: relPath } : {}),
528
+ content,
529
+ machine: gateMachine(),
530
+ env: detectEnv(),
531
+ ...(flags.project ? { projectId: String(flags.project) } : {}),
532
+ });
533
+ source = 'server';
534
+ if (!flags.json) console.log('');
535
+ } catch (e) {
536
+ if (!flags.json) {
537
+ console.log(yellow('backend unavailable'));
538
+ console.error(` ${yellow('⚠')} ${e.message} ${dim('— falling back to on-machine analysis')}`);
539
+ }
540
+ // --strict = fail-closed: an outage fails the build, because org policy
541
+ // could not be confirmed. Still show what the local analysis found.
542
+ if (flags.strict) {
543
+ printGateResult(localAsGateResult(local, name, kind), 'local', flags);
544
+ if (!flags.json) console.log(` ${red('✗ Failing closed (--strict): backend unreachable, org policy unverified.')}\n`);
545
+ process.exitCode = 1;
546
+ return;
547
+ }
548
+ }
549
+ } else if (!flags.json) {
550
+ console.error(` ${dim('Not enrolled — on-machine analysis only. Run')} ${bold('shomra init')} ${dim('to also apply org policy.')}`);
551
+ }
552
+
553
+ const base = res || localAsGateResult(local, name, kind);
554
+ // Fold in on-machine SAST: the artifact itself if it is a source file / model
555
+ // config, plus a skill's bundled scripts (the backend only saw one file).
556
+ const final = mergeSastIntoResult(base, collectLocalSast({ fullPath: fullTarget, relPath, kind, content }));
557
+ printGateResult(final, source, flags);
558
+
559
+ if (final.decision === 'BLOCK') process.exitCode = 1;
560
+ else if (final.decision === 'FLAG' && flags.strict) process.exitCode = 2;
561
+ }
562
+
563
+ // ── LLM Guard proxy: guardrail every LLM call from this machine ──
564
+ //
565
+ // shomra llm-proxy [--port 4141] [--project <projectId>]
566
+ //
567
+ // Starts a local proxy that forwards OpenAI/Anthropic SDK traffic through the
568
+ // Shomra backend, where every prompt and completion is screened against org
569
+ // policy. Point your SDK at it with an env var — zero code changes:
570
+ //
571
+ // OPENAI_BASE_URL = http://127.0.0.1:4141/openai/v1
572
+ // ANTHROPIC_BASE_URL = http://127.0.0.1:4141/anthropic
573
+ //
574
+ // Blocked calls come back as a provider-shaped HTTP 403, so SDKs raise a
575
+ // normal API error with the block reason. Keep your real provider key in the
576
+ // usual env var (the SDK sends it; Shomra passes it through) — or set the org
577
+ // key on the backend and use your shm_ key as the provider key.
578
+ //
579
+ // Providers mirror the backend registry (UPSTREAM in llm-proxy.service.ts):
580
+ // openai + every OpenAI-compatible API (groq/mistral/xai/deepseek/openrouter/
581
+ // together) speak the OpenAI wire format; anthropic and gemini have their own.
582
+
583
+ const LLM_PROVIDERS = ['openai', 'anthropic', 'gemini', 'groq', 'mistral', 'xai', 'deepseek', 'openrouter', 'together'];
584
+
585
+ async function cmdLlmProxy(flags) {
586
+ const cfg = loadConfig();
587
+ const { apiKey, url } = resolveSettings(cfg);
588
+ if (!apiKey) {
589
+ console.error('\n' + red('✗') + ' Not configured. Run ' + bold('shomra init --key shm_live_…') + ' first.');
590
+ process.exit(1);
591
+ }
592
+ const port = parseInt(flags.port, 10) || 4141;
593
+ const project = flags.project ? String(flags.project) : null;
594
+ const agentId = resolveAgentIdentityHandle(flags);
595
+ const actor = `${os.hostname()}/${os.userInfo().username}`;
596
+ // One correlation id per proxy run — the platform groups this session's
597
+ // inspections together. Callers can override per request with their own
598
+ // x-shomra-session header.
599
+ const sessionId = `proxy-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
600
+ const { createServer } = await import('node:http');
601
+
602
+ const providerRe = new RegExp(`^/(${LLM_PROVIDERS.join('|')})(/.*)?$`);
603
+ const server = createServer(async (req, res) => {
604
+ const m = String(req.url).match(providerRe);
605
+ if (!m) {
606
+ res.writeHead(404, { 'content-type': 'application/json' });
607
+ res.end(JSON.stringify({ error: { message: `Unknown route — use /<provider>/… (providers: ${LLM_PROVIDERS.join(', ')})` } }));
608
+ return;
609
+ }
610
+ const route = `/llm/${m[1]}${m[2] ?? '/'}`;
611
+ const chunks = [];
612
+ for await (const ch of req) chunks.push(ch);
613
+
614
+ const headers = { ...req.headers };
615
+ delete headers.host;
616
+ delete headers.connection;
617
+ delete headers['content-length'];
618
+ delete headers['accept-encoding']; // let fetch negotiate + transparently decompress
619
+ delete headers.expect; // undici rejects "Expect: 100-continue" (some HTTP clients add it)
620
+ headers['x-shomra-key'] = apiKey;
621
+ headers['x-shomra-actor'] = actor;
622
+ if (cfg.machineId) headers['x-shomra-machine'] = cfg.machineId;
623
+ headers['x-shomra-source'] = 'shomra llm-proxy';
624
+ if (!headers['x-shomra-session']) headers['x-shomra-session'] = sessionId;
625
+ if (project) headers['x-shomra-project'] = project;
626
+ // Present the non-human agent identity so the guard authorizes THIS agent
627
+ // (unless the caller already set its own per-request x-shomra-agent).
628
+ if (agentId && !headers['x-shomra-agent']) headers['x-shomra-agent'] = agentId;
629
+
630
+ let up;
631
+ try {
632
+ up = await fetch(`${url}${route}`, {
633
+ method: req.method,
634
+ headers,
635
+ body: ['GET', 'HEAD'].includes(req.method) ? undefined : Buffer.concat(chunks),
636
+ });
637
+ } catch (e) {
638
+ const cause = e.cause ? ` (${e.cause.code ?? ''} ${e.cause.message ?? e.cause})` : '';
639
+ res.writeHead(502, { 'content-type': 'application/json' });
640
+ res.end(JSON.stringify({ error: { message: `Shomra backend unreachable at ${url}: ${e.message}${cause}` } }));
641
+ console.log(` ${red('✗')} ${req.method} ${req.url} ${red('backend unreachable')}${dim(cause)} ${dim('hdrs: ' + Object.keys(headers).join(','))}`);
642
+ return;
643
+ }
644
+
645
+ const outHeaders = {};
646
+ for (const [k, v] of up.headers) {
647
+ if (!['content-length', 'transfer-encoding', 'content-encoding', 'connection'].includes(k)) outHeaders[k] = v;
648
+ }
649
+ res.writeHead(up.status, outHeaders);
650
+ try {
651
+ if (up.body) for await (const chunk of up.body) res.write(chunk);
652
+ } catch { /* client hung up mid-stream */ }
653
+ res.end();
654
+
655
+ const mark = up.status === 403 ? red('BLOCKED') : up.status >= 400 ? yellow(String(up.status)) : green(String(up.status));
656
+ console.log(` ${dim(new Date().toTimeString().slice(0, 8))} ${bold(m[1].padEnd(10))} ${dim(req.method)} ${m[2] ?? '/'} ${mark}`);
657
+ });
658
+
659
+ server.listen(port, '127.0.0.1', () => {
660
+ console.log(bold(cyan('\n Shomra LLM Guard')) + dim(` — local proxy v${VERSION}`));
661
+ console.log(` ${green('●')} Listening on ${bold(`http://127.0.0.1:${port}`)} ${dim('→ ' + url + ' → provider')}`);
662
+ if (project) console.log(` ${dim('Project ')} ${project}`);
663
+ console.log(` ${dim('Actor ')} ${actor}\n`);
664
+ console.log(bold(' Route your SDKs through the guard (no code changes):'));
665
+ console.log(dim(' OpenAI / Anthropic (PowerShell)'));
666
+ console.log(` $env:OPENAI_BASE_URL = "http://127.0.0.1:${port}/openai/v1"`);
667
+ console.log(` $env:ANTHROPIC_BASE_URL = "http://127.0.0.1:${port}/anthropic"`);
668
+ console.log(dim(' OpenAI / Anthropic (bash/zsh)'));
669
+ console.log(` export OPENAI_BASE_URL=http://127.0.0.1:${port}/openai/v1`);
670
+ console.log(` export ANTHROPIC_BASE_URL=http://127.0.0.1:${port}/anthropic`);
671
+ console.log(dim(' Gemini — point the Google GenAI SDK base URL at'));
672
+ console.log(` http://127.0.0.1:${port}/gemini`);
673
+ console.log(dim(` OpenAI-compatible (${['groq', 'mistral', 'xai', 'deepseek', 'openrouter', 'together'].join(', ')}) — set the SDK baseURL to`));
674
+ console.log(` http://127.0.0.1:${port}/<provider>/v1`);
675
+ console.log(dim('\n Prompts and completions are screened against org policy;'));
676
+ console.log(dim(' blocked calls return HTTP 403 with the reason. Ctrl+C to stop.\n'));
677
+ });
678
+ }
679
+
680
+ // ── batch gate: vet every AI artifact in a repo/dir (the CI pre-merge story) ──
681
+ //
682
+ // shomra gate --all [dir] [--strict] [--json] [--project <id>]
683
+ //
684
+ // Walks the tree for the AI-artifact surfaces an LLM can run (MCP configs,
685
+ // Skills, slash commands, subagents, hooks, rules files), gates each, and
686
+ // aggregates: exits 1 if ANY is BLOCKed (2 if any FLAG with --strict). Drop it
687
+ // in a CI job to fail the build when a risky artifact lands in the repo.
688
+
689
+ const ARTIFACT_MATCHERS = [
690
+ { kind: 'mcp', re: /(^|\/)\.?mcp\.json$/i },
691
+ { kind: 'mcp', re: /(^|\/)\.(vscode|cursor)\/mcp\.json$/i },
692
+ { kind: 'skill', re: /(^|\/)SKILL\.md$/i },
693
+ { kind: 'command', re: /(^|\/)\.claude\/commands\/[^/]+\.md$/i },
694
+ { kind: 'subagent', re: /(^|\/)\.claude\/agents\/[^/]+\.md$/i },
695
+ { kind: 'hook', re: /(^|\/)\.claude\/settings(\.local)?\.json$/i },
696
+ { kind: 'agent-card', re: /(^|\/)\.well-known\/agent(-card)?\.json$/i },
697
+ { kind: 'agent-card', re: /(^|\/)agent[-_]card\.json$/i },
698
+ { kind: 'rules', re: /(^|\/)(CLAUDE|AGENTS|GEMINI|CONVENTIONS)\.md$/i },
699
+ { kind: 'rules', re: /(^|\/)\.(cursorrules|windsurfrules|clinerules|aiderrules|continuerules|goosehints)$/i },
700
+ { kind: 'rules', re: /(^|\/)\.github\/copilot-instructions\.md$/i },
701
+ { kind: 'rules', re: /(^|\/)\.cursor\/rules\/[^/]+\.mdc$/i },
702
+ { kind: 'memory', re: /(^|\/)MEMORY\.md$/i },
703
+ { kind: 'memory', re: /(^|\/)(mem0|letta_memory|memgpt_memory)\.json$/i },
704
+ { kind: 'memory', re: /(^|\/)(\.mem0|\.letta|\.memgpt|memory)\/[^/]+\.(md|json)$/i },
705
+ ];
706
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'out', 'vendor', '.venv', '__pycache__']);
707
+ const MAX_ARTIFACT_BYTES = 1_000_000;
708
+
709
+ function walkArtifacts(root) {
710
+ const found = [];
711
+ const stack = [root];
712
+ while (stack.length) {
713
+ const dir = stack.pop();
714
+ let entries;
715
+ try {
716
+ entries = fs.readdirSync(dir, { withFileTypes: true });
717
+ } catch {
718
+ continue;
719
+ }
720
+ for (const ent of entries) {
721
+ const full = path.join(dir, ent.name);
722
+ if (ent.isDirectory()) {
723
+ if (!SKIP_DIRS.has(ent.name)) stack.push(full);
724
+ continue;
725
+ }
726
+ const rel = path.relative(root, full).split(path.sep).join('/');
727
+ const match = ARTIFACT_MATCHERS.find((m) => m.re.test(rel));
728
+ if (match) found.push({ full, rel, kind: match.kind });
729
+ }
730
+ }
731
+ return found;
732
+ }
733
+
734
+ // ── local SAST integration ──────────────────────────────────────────
735
+ // The gate's flat/structural checks catch payloads embedded in the artifact
736
+ // text; this catches the code-level RCE shapes a skill's shipped .py/.js helper
737
+ // (or a model config.json) carries — eval/exec/pickle/child_process/decode-and-
738
+ // run/trust_remote_code/auto_map — which the platform's workspace + model scans
739
+ // catch server-side. Runs the SAME rule engine ON-MACHINE (agent/code-sast.mjs).
740
+ const MAX_SAST_FILES = 60;
741
+
742
+ // Bounded walk of a skill's directory for the source files it bundles + executes.
743
+ function walkScripts(root) {
744
+ const found = [];
745
+ const stack = [root];
746
+ while (stack.length && found.length < MAX_SAST_FILES) {
747
+ const dir = stack.pop();
748
+ let entries;
749
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
750
+ for (const ent of entries) {
751
+ const full = path.join(dir, ent.name);
752
+ if (ent.isDirectory()) { if (!SKIP_DIRS.has(ent.name)) stack.push(full); continue; }
753
+ if (isScannableSource(ent.name) || isModelConfig(ent.name)) {
754
+ found.push({ full, rel: path.relative(process.cwd(), full).split(path.sep).join('/') });
755
+ if (found.length >= MAX_SAST_FILES) break;
756
+ }
757
+ }
758
+ }
759
+ return found;
760
+ }
761
+
762
+ /** Map a SAST hit into a gate-finding, preserving the rich evidence (--json/IDE). */
763
+ function sastToFinding(h) {
764
+ const base = h.file ? h.file.split('/').pop() : '';
765
+ return {
766
+ severity: h.severity,
767
+ title: `Risky code — ${h.title}${base ? ` in ${base}` : ''} (${h.sink})`,
768
+ remediationText: h.remediation,
769
+ ...(h.line ? { line: h.line } : {}),
770
+ analysis: 'sast', ruleId: h.ruleId, cwe: h.cwe, sink: h.sink, source: h.source,
771
+ file: h.file, snippet: h.snippet, snippetStartLine: h.snippetStartLine,
772
+ };
773
+ }
774
+
775
+ // Collect local SAST findings for one gated artifact: the artifact itself when
776
+ // it is a source file / model config (`gate server.js`), plus — for a skill —
777
+ // the scripts bundled in its directory (mirrors the platform's per-skill scan).
778
+ function collectLocalSast({ fullPath, relPath, kind, content }) {
779
+ const out = [];
780
+ if (relPath && (isScannableSource(relPath) || isModelConfig(relPath))) {
781
+ for (const h of scanSourceFile(content || '', relPath)) out.push(sastToFinding(h));
782
+ }
783
+ const isSkill = kind === 'skill' || /(^|\/)SKILL\.md$/i.test(relPath || '');
784
+ if (isSkill && fullPath) {
785
+ for (const s of walkScripts(path.dirname(fullPath))) {
786
+ let text;
787
+ try { if (fs.statSync(s.full).size > MAX_ARTIFACT_BYTES) continue; text = fs.readFileSync(s.full, 'utf8'); } catch { continue; }
788
+ for (const h of scanSourceFile(text, s.rel)) out.push(sastToFinding(h));
789
+ }
790
+ }
791
+ return out;
792
+ }
793
+
794
+ const DEC_RANK = { ALLOW: 0, FLAG: 1, BLOCK: 2 };
795
+ /** Fold SAST findings into a gate result, escalating the decision to the worse of
796
+ * the two (rule-origin SAST is high-confidence; it never downgrades). */
797
+ function mergeSastIntoResult(result, sastFindings) {
798
+ if (!sastFindings || !sastFindings.length) return result;
799
+ const findings = [...(result.findings || []), ...sastFindings];
800
+ const g = grade(findings);
801
+ const decision = DEC_RANK[g.verdict] > DEC_RANK[result.decision] ? g.verdict : result.decision;
802
+ return { ...result, decision, riskScore: Math.max(result.riskScore || 0, g.riskScore), findingCount: findings.length, findings };
803
+ }
804
+
805
+ // ── suppression: .shomraignore + inline // shomra-ignore + baseline ──────
806
+ // Devs need a friction-free escape hatch or a single false positive gets the
807
+ // tool deleted. Three layers, all re-grade the artifact so a fully-suppressed
808
+ // file drops to ALLOW and never fails the build:
809
+ // • .shomraignore — repo file: `glob` (skip file) or `glob :: title-substr`.
810
+ // • inline comment — `// shomra-ignore[: reason]` / `# shomra-ignore` on the
811
+ // finding's line or the one above; `shomra-ignore-file`
812
+ // anywhere in the first lines skips the whole file.
813
+ // • baseline — `.shomra/baseline.json` of accepted fingerprints
814
+ // (`shomra baseline`), so only NEW findings fail.
815
+ const IGNORE_MARK = /(?:\/\/|#|<!--|;)\s*shomra-ignore(-file|-next-line)?\b[:\s]?(.*)$/i;
816
+
817
+ function globToRe(glob) {
818
+ const esc = String(glob).trim().replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, '').replace(/\*/g, '[^/]*').replace(//g, '.*').replace(/\?/g, '.');
819
+ return new RegExp('^' + esc + '$', 'i');
820
+ }
821
+
822
+ // Parse .shomraignore into file-skip globs and per-finding (glob :: substr) rules.
823
+ function loadIgnoreRules(root) {
824
+ const fileGlobs = [], findingRules = [];
825
+ let raw;
826
+ try { raw = fs.readFileSync(path.join(root, '.shomraignore'), 'utf8'); } catch { return { fileGlobs, findingRules }; }
827
+ for (const line of raw.split(/\r?\n/)) {
828
+ const t = line.trim();
829
+ if (!t || t.startsWith('#')) continue;
830
+ const sep = t.indexOf('::');
831
+ if (sep !== -1) findingRules.push({ re: globToRe(t.slice(0, sep).trim()), titleSub: t.slice(sep + 2).trim().toLowerCase() });
832
+ else fileGlobs.push(globToRe(t));
833
+ }
834
+ return { fileGlobs, findingRules };
835
+ }
836
+
837
+ function loadBaseline(root) {
838
+ try { return new Set(JSON.parse(fs.readFileSync(path.join(root, '.shomra', 'baseline.json'), 'utf8')).fingerprints || []); } catch { return null; }
839
+ }
840
+ // Line-independent identity so a finding stays suppressed when code moves.
841
+ function findingFingerprint(relPath, f) {
842
+ return crypto.createHash('sha1').update(`${relPath}::${f.ruleId || f.title}`).digest('hex').slice(0, 16);
843
+ }
844
+
845
+ // Inline-comment suppression for one finding, using a per-file line cache.
846
+ function inlineSuppressed(fullPath, line, cache) {
847
+ if (!fullPath) return false;
848
+ let lines = cache.get(fullPath);
849
+ if (lines === undefined) {
850
+ try { lines = fs.readFileSync(fullPath, 'utf8').split(/\r?\n/); } catch { lines = null; }
851
+ cache.set(fullPath, lines);
852
+ }
853
+ if (!lines) return false;
854
+ // Whole-file opt-out in the first 5 lines. `shomra-ignore-file` is accepted as
855
+ // a bare token too (not just in a comment) so JSON/config files — which have no
856
+ // comment syntax — can still opt out with a `"_shomra": "shomra-ignore-file"` key.
857
+ for (let i = 0; i < Math.min(5, lines.length); i++) {
858
+ if (/\bshomra-ignore-file\b/i.test(lines[i])) return true;
859
+ }
860
+ if (!line) return false;
861
+ const onLine = IGNORE_MARK.exec(lines[line - 1] || '');
862
+ if (onLine && (!onLine[1] || onLine[1].toLowerCase() !== '-file')) return true;
863
+ const above = IGNORE_MARK.exec(lines[line - 2] || '');
864
+ if (above && (!above[1] || above[1].toLowerCase() === '-next-line')) return true;
865
+ return false;
866
+ }
867
+
868
+ // Why (if at all) a finding is suppressed: ignore-rule / inline / baseline.
869
+ function suppressionReason(r, f, rules, baseline, cache) {
870
+ if (rules.fileGlobs.some((re) => re.test(r.path))) return 'ignored (.shomraignore)';
871
+ const title = String(f.title || '').toLowerCase();
872
+ if (rules.findingRules.some((rule) => rule.re.test(r.path) && title.includes(rule.titleSub))) return 'ignored (.shomraignore)';
873
+ if (baseline && baseline.has(findingFingerprint(r.path, f))) return 'baseline';
874
+ if (inlineSuppressed(r.full, f.line, cache)) return 'inline';
875
+ return null;
876
+ }
877
+
878
+ // Apply suppression to a gate result, re-grading from the surviving findings.
879
+ function suppressResult(r, rules, baseline, cache) {
880
+ const kept = [], suppressed = [];
881
+ for (const f of r.findings || []) {
882
+ const reason = suppressionReason(r, f, rules, baseline, cache);
883
+ (reason ? suppressed : kept).push(reason ? { ...f, suppressedBy: reason } : f);
884
+ }
885
+ if (!suppressed.length) return r;
886
+ const g = grade(kept);
887
+ return { ...r, findings: kept, decision: g.verdict, riskScore: g.riskScore, findingCount: kept.length, suppressedFindings: suppressed, suppressedCount: suppressed.length };
888
+ }
889
+
890
+ // Suppress a whole results array and recompute the blocked/flagged/suppressed
891
+ // tallies. Central so check / gate --all / single gate behave identically.
892
+ function applySuppressions(results, root, { baseline } = {}) {
893
+ const rules = loadIgnoreRules(root);
894
+ const base = baseline ? loadBaseline(root) : null;
895
+ const cache = new Map();
896
+ let blocked = 0, flagged = 0, suppressed = 0;
897
+ const out = results.map((r) => {
898
+ const s = suppressResult(r, rules, base, cache);
899
+ suppressed += s.suppressedCount || 0;
900
+ if (s.decision === 'BLOCK') blocked++;
901
+ else if (s.decision === 'FLAG') flagged++;
902
+ return s;
903
+ });
904
+ return { results: out, blocked, flagged, suppressed };
905
+ }
906
+
907
+ // ── SARIF 2.1.0 output — native inline annotations in GitHub / GitLab PRs ──
908
+ const SARIF_LEVEL = { CRITICAL: 'error', HIGH: 'error', MEDIUM: 'warning', LOW: 'note', INFO: 'note' };
909
+ const SARIF_SEC = { CRITICAL: '9.0', HIGH: '7.5', MEDIUM: '5.0', LOW: '3.0', INFO: '1.0' };
910
+ function sarifRuleId(f) {
911
+ return f.ruleId || 'shomra.' + String(f.title || 'finding').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60);
912
+ }
913
+ function toSarif(results) {
914
+ const rules = new Map();
915
+ const sarifResults = [];
916
+ for (const r of results) {
917
+ for (const f of r.findings || []) {
918
+ const id = sarifRuleId(f);
919
+ if (!rules.has(id)) rules.set(id, { id, name: id, shortDescription: { text: String(f.title || id).slice(0, 200) }, ...(f.cwe ? { properties: { cwe: f.cwe, tags: ['security', f.cwe] } } : { properties: { tags: ['security'] } }) });
920
+ sarifResults.push({
921
+ ruleId: id,
922
+ level: SARIF_LEVEL[f.severity] || 'warning',
923
+ message: { text: f.remediationText ? `${f.title} — ${f.remediationText}` : String(f.title || id) },
924
+ locations: [{ physicalLocation: { artifactLocation: { uri: f.file || r.path }, ...(f.line ? { region: { startLine: f.line } } : {}) } }],
925
+ properties: { severity: f.severity, 'security-severity': SARIF_SEC[f.severity] || '5.0', ...(f.cwe ? { cwe: f.cwe } : {}) },
926
+ });
927
+ }
928
+ }
929
+ return {
930
+ $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
931
+ version: '2.1.0',
932
+ runs: [{ tool: { driver: { name: 'Shomra', informationUri: 'https://shomra.dev', version: VERSION, rules: [...rules.values()] } }, results: sarifResults }],
933
+ };
934
+ }
935
+
936
+ // ── policy-as-code: .shomra/policy.yml — team gate rules, versioned in the repo ──
937
+ // Devs prefer config-in-repo over a dashboard. A committed policy lets a team set
938
+ // its own block/flag thresholds and allow-list, reviewed in PRs like any code.
939
+ // block: high # min severity that BLOCKS (critical|high|medium|low|none)
940
+ // flag: medium # min severity that FLAGS
941
+ // allow: # finding-title substrings to always downgrade away
942
+ // - "IPv4 address"
943
+ // Authority: for a LOCAL verdict the repo policy fully re-grades; when the backend
944
+ // returned an org decision, the repo policy can only make it STRICTER (worst-wins),
945
+ // never loosen org enforcement — mirroring the platform's policy hierarchy.
946
+ const SEV_THRESH = { none: 99, critical: 5, high: 4, medium: 3, low: 2, info: 1 };
947
+ const SEV_RANK_LOCAL = { INFO: 1, LOW: 2, MEDIUM: 3, HIGH: 4, CRITICAL: 5 };
948
+ const WEIGHT_LOCAL = { INFO: 2, LOW: 8, MEDIUM: 20, HIGH: 40, CRITICAL: 70 };
949
+
950
+ function parseSimpleYaml(text) {
951
+ const data = {}; let key = null;
952
+ for (const raw of String(text).split(/\r?\n/)) {
953
+ const line = raw.replace(/\s+#.*$/, '');
954
+ if (!line.trim() || line.trim().startsWith('#')) continue;
955
+ const li = /^\s*-\s+(.*)$/.exec(line);
956
+ if (li && key) { (Array.isArray(data[key]) ? data[key] : (data[key] = [])).push(unquote(li[1].trim())); continue; }
957
+ const kv = /^([A-Za-z0-9_.-]+)\s*:\s*(.*)$/.exec(line);
958
+ if (!kv) continue;
959
+ key = kv[1];
960
+ const v = kv[2].trim();
961
+ data[key] = v === '' ? (data[key] ?? null) : v.startsWith('[') ? v.replace(/^\[|\]$/g, '').split(',').map((s) => unquote(s.trim())).filter(Boolean) : unquote(v);
962
+ }
963
+ return data;
964
+ }
965
+ function unquote(s) { return String(s).replace(/^["']|["']$/g, ''); }
966
+
967
+ function loadRepoPolicy(root) {
968
+ let text, file;
969
+ for (const f of ['policy.yml', 'policy.yaml', 'policy.json']) {
970
+ try { text = fs.readFileSync(path.join(root, '.shomra', f), 'utf8'); file = f; break; } catch { /* next */ }
971
+ }
972
+ if (text === undefined) return null;
973
+ let raw;
974
+ if (file.endsWith('.json')) { try { raw = JSON.parse(text); } catch { return null; } }
975
+ else raw = parseSimpleYaml(text);
976
+ return {
977
+ block: SEV_THRESH[String(raw.block || '').toLowerCase()] ?? SEV_RANK_LOCAL.CRITICAL,
978
+ flag: SEV_THRESH[String(raw.flag || '').toLowerCase()] ?? SEV_RANK_LOCAL.HIGH,
979
+ allow: [].concat(raw.allow || []).map((s) => String(s).toLowerCase()),
980
+ };
981
+ }
982
+
983
+ // Re-grade a result under a repo policy. Drops allow-listed findings, applies the
984
+ // team's block/flag thresholds; only tightens a server decision, fully sets a
985
+ // local one.
986
+ function applyRepoPolicy(r, policy) {
987
+ if (!policy) return r;
988
+ let findings = r.findings || [];
989
+ let dropped = 0;
990
+ if (policy.allow.length) {
991
+ const keep = [];
992
+ for (const f of findings) {
993
+ if (policy.allow.some((sub) => sub && String(f.title || '').toLowerCase().includes(sub))) dropped++;
994
+ else keep.push(f);
995
+ }
996
+ findings = keep;
997
+ }
998
+ let worst = 0;
999
+ for (const f of findings) worst = Math.max(worst, SEV_RANK_LOCAL[f.severity] || 0);
1000
+ const pv = worst >= policy.block ? 'BLOCK' : worst >= policy.flag ? 'FLAG' : 'ALLOW';
1001
+ const riskScore = Math.min(100, findings.reduce((s, f) => s + (WEIGHT_LOCAL[f.severity] || 0), 0));
1002
+ // Server decision is authoritative — repo policy can only tighten it. A purely
1003
+ // local decision is fully replaced by the repo policy.
1004
+ const decision = r.source === 'server' ? (DEC_RANK[pv] > DEC_RANK[r.decision] ? pv : r.decision) : pv;
1005
+ const extra = dropped ? { suppressedCount: (r.suppressedCount || 0) + dropped, suppressedFindings: [...(r.suppressedFindings || []), ...(r.findings || []).filter((f) => !findings.includes(f)).map((f) => ({ ...f, suppressedBy: 'policy allow' }))] } : {};
1006
+ return { ...r, findings, findingCount: findings.length, decision, riskScore: r.source === 'server' ? Math.max(r.riskScore || 0, riskScore) : riskScore, ...extra };
1007
+ }
1008
+
1009
+ // Gate a list of {full, rel, kind} artifacts: local analysis always runs, the
1010
+ // backend enriches when reachable (and is skipped for the rest of the batch
1011
+ // after one failure, so a CI job never eats one timeout per artifact). Prints
1012
+ // one line per artifact unless --json. Shared by `gate --all` and `check`.
1013
+ async function gateArtifactList(artifacts, { apiKey, url, env, flags, root }) {
1014
+ const results = [];
1015
+ const quiet = flags.json || flags.sarif; // machine-readable output → no progress chatter
1016
+ let blocked = 0;
1017
+ let flagged = 0;
1018
+ let suppressed = 0;
1019
+ let backendDown = false;
1020
+ // Suppression context (loaded once): .shomraignore + baseline + inline cache.
1021
+ const suppress = !flags['no-suppress'];
1022
+ const rules = suppress ? loadIgnoreRules(root || process.cwd()) : { fileGlobs: [], findingRules: [] };
1023
+ const baseline = suppress && !flags['no-baseline'] ? loadBaseline(root || process.cwd()) : null;
1024
+ const lineCache = new Map();
1025
+ // Team policy-as-code (.shomra/policy.yml) re-grades each result; --no-policy skips.
1026
+ const policy = flags['no-policy'] ? null : loadRepoPolicy(root || process.cwd());
1027
+
1028
+ // ── Phase 1: read + LOCAL analysis (sync, cheap) for every artifact ──
1029
+ const prepared = [];
1030
+ for (const a of artifacts) {
1031
+ let content;
1032
+ try {
1033
+ if (fs.statSync(a.full).size > MAX_ARTIFACT_BYTES) {
1034
+ if (!quiet) console.log(` ${gray('•')} ${dim(a.rel)} ${yellow('skipped (too large)')}`);
1035
+ continue;
1036
+ }
1037
+ content = fs.readFileSync(a.full, 'utf8');
1038
+ } catch { continue; }
1039
+ const local = localGate(content, { kind: a.kind, path: a.rel });
1040
+ const sast = collectLocalSast({ fullPath: a.full, relPath: a.rel, kind: a.kind, content });
1041
+ prepared.push({ a, content, local, sast });
1042
+ }
1043
+
1044
+ // ── Phase 2: backend enrich, BOUNDED-PARALLEL (was one sequential round-trip
1045
+ // per artifact). Order-preserving; on the first outage stop starting new
1046
+ // calls (a down backend never costs N timeouts).
1047
+ const server = new Array(prepared.length).fill(null);
1048
+ if (apiKey) {
1049
+ const conc = clampInt(process.env.SHOMRA_GATE_CONCURRENCY, 8, 1, 32);
1050
+ let next = 0;
1051
+ const worker = async () => {
1052
+ while (true) {
1053
+ const i = next++;
1054
+ if (i >= prepared.length || backendDown) return;
1055
+ const { a, content } = prepared[i];
1056
+ try {
1057
+ server[i] = await api(url, apiKey, '/gate/check', {
1058
+ kind: a.kind, path: a.rel, content, machine: gateMachine(), env,
1059
+ ...(flags.project ? { projectId: String(flags.project) } : {}),
1060
+ });
1061
+ } catch (e) {
1062
+ if (!backendDown && !quiet) console.log(` ${yellow('⚠')} ${dim('backend unavailable (' + e.message + ') — on-machine analysis for the rest')}`);
1063
+ backendDown = true;
1064
+ return;
1065
+ }
1066
+ }
1067
+ };
1068
+ await Promise.all(Array.from({ length: Math.min(conc, prepared.length) }, worker));
1069
+ }
1070
+
1071
+ // ── Phase 3: assemble in order — fold SAST, suppress, apply policy, tally, print ──
1072
+ for (let i = 0; i < prepared.length; i++) {
1073
+ const { a, content, local, sast } = prepared[i];
1074
+ const res = server[i];
1075
+ const source = res ? 'server' : 'local';
1076
+ const merged = mergeSastIntoResult(res || localAsGateResult(local, a.rel, a.kind), sast);
1077
+ const r0 = { path: a.rel, full: a.full, kind: a.kind, source, ...merged };
1078
+ const rs = suppress ? suppressResult(r0, rules, baseline, lineCache) : r0;
1079
+ const r = applyRepoPolicy(rs, policy);
1080
+ suppressed += r.suppressedCount || 0;
1081
+ results.push(r);
1082
+ if (r.decision === 'BLOCK') blocked++;
1083
+ else if (r.decision === 'FLAG') flagged++;
1084
+ if (!quiet) {
1085
+ const dc = r.decision === 'BLOCK' ? red : r.decision === 'FLAG' ? yellow : green;
1086
+ const supNote = r.suppressedCount ? dim(` · ${r.suppressedCount} suppressed`) : '';
1087
+ 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}`);
1088
+ for (const f of (r.findings || []).slice(0, 3)) {
1089
+ console.log(` ${SEV_COLOR[f.severity](String(f.severity).padEnd(8))} ${f.title}`);
1090
+ }
1091
+ }
1092
+ }
1093
+ return { results, blocked, flagged, suppressed, backendDown };
1094
+ }
1095
+
1096
+ async function cmdGateAll(flags, positional, { apiKey, url }) {
1097
+ // `--all <dir>` sets flags.all to the dir; bare `--all` leaves it true → use positional or cwd.
1098
+ const dirArg = typeof flags.all === 'string' ? flags.all : positional[0] || '.';
1099
+ const root = path.resolve(dirArg);
1100
+ const env = detectEnv();
1101
+ const artifacts = walkArtifacts(root);
1102
+
1103
+ if (!artifacts.length) {
1104
+ if (flags.json) console.log(JSON.stringify({ scanned: 0, results: [] }, null, 2));
1105
+ else console.log(dim(`\n No AI artifacts found under ${root}. Nothing to gate.\n`));
1106
+ return;
1107
+ }
1108
+
1109
+ if (!flags.json && !flags.sarif) console.log(bold(cyan('\n Shomra gate')) + dim(` — batch (${artifacts.length} artifact${artifacts.length > 1 ? 's' : ''} · ${env.environment}${env.ciProvider ? ' · ' + env.ciProvider : ''})`));
1110
+
1111
+ const { results, blocked, flagged, suppressed, backendDown } = await gateArtifactList(artifacts, { apiKey, url, env, flags, root });
1112
+
1113
+ // --strict is fail-closed: if the backend was unreachable we can't confirm org
1114
+ // policy, so fail the build even if local analysis was clean.
1115
+ const strictOutage = backendDown && flags.strict;
1116
+
1117
+ if (flags.sarif) {
1118
+ console.log(JSON.stringify(toSarif(results), null, 2));
1119
+ if (blocked > 0 || strictOutage) process.exitCode = 1;
1120
+ else if (flagged > 0 && flags.strict) process.exitCode = 2;
1121
+ return;
1122
+ }
1123
+ if (flags.json) {
1124
+ console.log(JSON.stringify({ scanned: results.length, blocked, flagged, suppressed, backendDown, environment: env.environment, results }, null, 2));
1125
+ } else {
1126
+ console.log(
1127
+ '\n ' +
1128
+ (blocked > 0
1129
+ ? red(`✗ ${blocked} blocked`) + dim(` · ${flagged} flagged · ${results.length - blocked - flagged} allowed`)
1130
+ : flagged > 0
1131
+ ? yellow(`⚠ ${flagged} flagged`) + dim(` · ${results.length - flagged} allowed`)
1132
+ : green(`✓ All ${results.length} artifacts allowed.`)) +
1133
+ (suppressed ? dim(` · ${suppressed} suppressed`) : '') +
1134
+ (backendDown ? yellow(' (on-machine analysis — org policy not applied)') : dim(' — full activity in the Shomra dashboard → Gate Activity')) +
1135
+ '\n',
1136
+ );
1137
+ if (strictOutage) console.log(` ${red('✗ Failing closed (--strict): backend unreachable, org policy unverified.')}\n`);
1138
+ }
1139
+
1140
+ // Set exitCode (not process.exit) so pending sockets drain cleanly on Windows.
1141
+ if (blocked > 0 || strictOutage) process.exitCode = 1;
1142
+ else if (flagged > 0 && flags.strict) process.exitCode = 2;
1143
+ }
1144
+
1145
+ // ── the one dev command: "is my repo safe?" ─────────────────────
1146
+ //
1147
+ // shomra check # gate every AI artifact under the repo
1148
+ // shomra check --staged # only git-STAGED artifacts (pre-commit / on-save)
1149
+ // shomra check --changed # only artifacts changed vs HEAD
1150
+ // shomra check --fix # remediate what's blocked/flagged, in place
1151
+ // shomra check --json # machine-readable (what the IDE extension calls)
1152
+ //
1153
+ // Local-first like `gate`: real on-machine analysis always runs, so a verdict
1154
+ // comes back with no backend and no key; enrolling layers org policy on top.
1155
+ // Exit 0 = clean, 1 = blocked, 2 = flagged with --strict.
1156
+ async function cmdCheck(flags, positional) {
1157
+ const cfg = loadConfig();
1158
+ const { apiKey, url } = resolveSettings(cfg);
1159
+ const root = path.resolve(positional[0] || flags.path || '.');
1160
+ const env = detectEnv();
1161
+
1162
+ let artifacts = walkArtifacts(root);
1163
+ // Scope to the changed/staged set when asked — the fast pre-commit / on-save loop.
1164
+ if (flags.staged || flags.changed) {
1165
+ const changed = gitChangedArtifacts(root, { staged: !!flags.staged });
1166
+ if (changed === null) {
1167
+ if (!flags.json) console.error(` ${yellow('⚠')} ${dim('not a git repo (or git unavailable) — checking the whole tree')}`);
1168
+ } else {
1169
+ const set = new Set(changed);
1170
+ artifacts = artifacts.filter((a) => set.has(a.rel));
1171
+ }
1172
+ }
1173
+
1174
+ if (!artifacts.length) {
1175
+ if (flags.json) console.log(JSON.stringify({ scanned: 0, blocked: 0, flagged: 0, results: [] }, null, 2));
1176
+ else console.log(green('\n ✓ No AI artifacts to check') + dim((flags.staged || flags.changed) ? ' in the changed set.' : ` under ${root}.`) + '\n');
1177
+ return;
1178
+ }
1179
+
1180
+ if (!flags.json && !flags.sarif) {
1181
+ const scope = flags.staged ? 'staged' : flags.changed ? 'changed' : env.environment;
1182
+ console.log(bold(cyan('\n Shomra check')) + dim(` — ${artifacts.length} artifact${artifacts.length > 1 ? 's' : ''} · ${scope}${env.ciProvider ? ' · ' + env.ciProvider : ''}`));
1183
+ if (!apiKey) console.error(` ${dim('On-machine analysis only — run')} ${bold('shomra init')} ${dim('to also apply org policy.')}`);
1184
+ }
1185
+
1186
+ const { results, blocked, flagged, suppressed, backendDown } = await gateArtifactList(artifacts, { apiKey, url, env, flags, root });
1187
+
1188
+ if (flags.sarif) {
1189
+ console.log(JSON.stringify(toSarif(results), null, 2));
1190
+ if (blocked) process.exitCode = 1;
1191
+ else if (flagged && flags.strict) process.exitCode = 2;
1192
+ return;
1193
+ }
1194
+
1195
+ // --fix: remediate the artifacts that aren't clean, in place (each fix is
1196
+ // generated on the platform and written back to the local file).
1197
+ let fixed = 0;
1198
+ if (flags.fix && (blocked || flagged)) {
1199
+ if (apiKey) {
1200
+ if (!flags.json) console.log(dim('\n Fixing flagged artifacts…'));
1201
+ for (const r of results) {
1202
+ if (r.decision === 'ALLOW') continue;
1203
+ const done = await fixOneFile(r.full, { apiKey, url, flags: { ...flags, apply: true, quiet: flags.json } });
1204
+ if (done) fixed++;
1205
+ }
1206
+ } else if (!flags.json) {
1207
+ console.error(` ${yellow('⚠')} ${dim('--fix needs enrollment (the fix runs on the platform). Run')} ${bold('shomra init')}${dim('.')}`);
1208
+ }
1209
+ }
1210
+
1211
+ const strictOutage = backendDown && flags.strict;
1212
+ if (flags.json) {
1213
+ console.log(JSON.stringify({ scanned: results.length, blocked, flagged, suppressed, fixed, backendDown, environment: env.environment, results }, null, 2));
1214
+ } else {
1215
+ console.log(
1216
+ '\n ' +
1217
+ (blocked
1218
+ ? red(`✗ ${blocked} blocked`) + dim(` · ${flagged} flagged · ${results.length - blocked - flagged} clean`)
1219
+ : flagged
1220
+ ? yellow(`⚠ ${flagged} flagged`) + dim(` · ${results.length - flagged} clean`)
1221
+ : green(`✓ All ${results.length} clean.`)) +
1222
+ (suppressed ? dim(` · ${suppressed} suppressed`) : '') +
1223
+ (backendDown ? yellow(' (on-machine only — org policy not applied)') : ''),
1224
+ );
1225
+ if (flags.fix && fixed) console.log(` ${green('✓')} ${dim(`applied ${fixed} fix${fixed > 1 ? 'es' : ''} — re-run`)} ${bold('shomra check')} ${dim('to confirm.')}`);
1226
+ else if (!flags.fix && (blocked || flagged)) console.log(dim(' Run ') + bold('shomra fix <file>') + dim(' or ') + bold('shomra check --fix') + dim(' to remediate.'));
1227
+ if (strictOutage) console.log(` ${red('✗ Failing closed (--strict): backend unreachable, org policy unverified.')}`);
1228
+ console.log('');
1229
+ }
1230
+
1231
+ if (blocked > 0 || strictOutage) process.exitCode = 1;
1232
+ else if (flagged > 0 && flags.strict) process.exitCode = 2;
1233
+ }
1234
+
1235
+ // ── shomra baseline: accept everything here, so only NEW findings fail ───────
1236
+ //
1237
+ // shomra baseline [dir] # write .shomra/baseline.json of current findings
1238
+ //
1239
+ // Adopt Shomra on a repo that already has findings without a wall of red: record
1240
+ // the current finding fingerprints (line-independent) as an accepted baseline;
1241
+ // subsequent `check`/`gate` suppress those and fail only on findings introduced
1242
+ // after. Commit .shomra/baseline.json so the whole team shares it. Re-run to
1243
+ // refresh after you've fixed things.
1244
+ async function cmdBaseline(flags, positional) {
1245
+ const cfg = loadConfig();
1246
+ const { apiKey, url } = resolveSettings(cfg);
1247
+ const root = path.resolve(positional[0] || flags.path || '.');
1248
+ const env = detectEnv();
1249
+ const artifacts = walkArtifacts(root);
1250
+ if (!artifacts.length) {
1251
+ console.log(dim(`\n No AI artifacts under ${root} — nothing to baseline.\n`));
1252
+ return;
1253
+ }
1254
+ if (!flags.json) process.stdout.write(dim(` Scanning ${artifacts.length} artifact${artifacts.length > 1 ? 's' : ''} to baseline… `));
1255
+ // Capture EVERY current finding (suppression off) so the baseline is complete.
1256
+ const { results } = await gateArtifactList(artifacts, { apiKey, url, env, flags: { ...flags, json: true, 'no-suppress': true }, root });
1257
+ const fingerprints = new Set();
1258
+ for (const r of results) for (const f of r.findings || []) fingerprints.add(findingFingerprint(r.path, f));
1259
+ const dir = path.join(root, '.shomra');
1260
+ fs.mkdirSync(dir, { recursive: true });
1261
+ const file = path.join(dir, 'baseline.json');
1262
+ fs.writeFileSync(file, JSON.stringify({ createdAt: new Date().toISOString(), agentVersion: VERSION, count: fingerprints.size, fingerprints: [...fingerprints] }, null, 2));
1263
+ const rel = path.relative(process.cwd(), file).split(path.sep).join('/');
1264
+ if (flags.json) {
1265
+ console.log(JSON.stringify({ baseline: rel, count: fingerprints.size, artifacts: results.length }, null, 2));
1266
+ return;
1267
+ }
1268
+ console.log(green('done'));
1269
+ console.log(`\n ${green('✓ Baseline written')} ${dim(`— ${fingerprints.size} finding(s) across ${results.length} artifact(s) accepted.`)}`);
1270
+ console.log(dim(` ${rel} — commit it so your team shares the baseline. Only NEW findings will fail now.`) + '\n');
1271
+ }
1272
+
1273
+ // Relative POSIX paths of AI artifacts that git reports as added/changed —
1274
+ // null when this isn't a git repo (or git is unavailable). Uses --relative so
1275
+ // paths line up with walkArtifacts' root-relative rels.
1276
+ function gitChangedArtifacts(root, { staged }) {
1277
+ const run = (args) => {
1278
+ try {
1279
+ return execSync(`git ${args}`, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000 }).toString();
1280
+ } catch {
1281
+ return null;
1282
+ }
1283
+ };
1284
+ const out = staged
1285
+ ? run('diff --cached --name-only --relative --diff-filter=ACM')
1286
+ : run('diff HEAD --name-only --relative --diff-filter=ACM');
1287
+ if (out === null) return null;
1288
+ const files = out.split('\n').map((s) => s.trim()).filter(Boolean);
1289
+ return files.filter((rel) => ARTIFACT_MATCHERS.some((m) => m.re.test(rel)));
1290
+ }
1291
+
1292
+ // ── shomra pr: review a pull request — inline findings on the diff ───────────
1293
+ //
1294
+ // shomra pr [--dry-run] [--strict] [--base <ref>] [--repo o/n] [--pr N] [--token T]
1295
+ // shomra pr --init # scaffold .github/workflows/shomra.yml
1296
+ //
1297
+ // Runs in CI on a pull_request event: gates the AI artifacts CHANGED in the PR
1298
+ // and posts a GitHub Check Run with inline annotations (they render right in the
1299
+ // Files-changed tab — no comment spam, updates each push). A BLOCK fails the
1300
+ // check (and the job); a FLAG warns (fails only with --strict). Enrolled runs
1301
+ // also apply org policy and land in Gate Activity. Uses the CI's GITHUB_TOKEN —
1302
+ // no GitHub App or webhook to stand up.
1303
+ const PR_WORKFLOW = `name: Shomra AI Security
1304
+ on: pull_request
1305
+ permissions:
1306
+ contents: read
1307
+ checks: write
1308
+ jobs:
1309
+ shomra:
1310
+ runs-on: ubuntu-latest
1311
+ steps:
1312
+ - uses: actions/checkout@v4
1313
+ with: { fetch-depth: 0 } # full history so the PR diff resolves
1314
+ - uses: actions/setup-node@v4
1315
+ with: { node-version: 20 }
1316
+ - run: npx @shomra/agent pr
1317
+ env:
1318
+ GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
1319
+ SHOMRA_API_KEY: \${{ secrets.SHOMRA_API_KEY }} # optional — applies org policy
1320
+ SHOMRA_URL: \${{ secrets.SHOMRA_URL }} # optional — your backend
1321
+ `;
1322
+
1323
+ function readGithubEvent() {
1324
+ try { return JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')); } catch { return {}; }
1325
+ }
1326
+ async function githubApi(token, method, apiPath, body) {
1327
+ const res = await fetch(`https://api.github.com${apiPath}`, {
1328
+ method,
1329
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', 'Content-Type': 'application/json', 'User-Agent': 'shomra-agent' },
1330
+ body: body ? JSON.stringify(body) : undefined,
1331
+ });
1332
+ if (!res.ok) throw new Error(`GitHub ${method} ${apiPath} → ${res.status} ${(await res.text()).slice(0, 200)}`);
1333
+ return res.json();
1334
+ }
1335
+ // AI artifacts changed in this PR vs its base branch (tries a few base spellings).
1336
+ function gitChangedVsBase(root, base) {
1337
+ const run = (args) => { try { return execSync(`git ${args}`, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).toString(); } catch { return null; } };
1338
+ let out = null;
1339
+ for (const b of [`origin/${base}`, base]) {
1340
+ out = run(`diff --name-only --relative --diff-filter=ACM ${b}...HEAD`);
1341
+ if (out !== null) break;
1342
+ }
1343
+ if (out === null) out = run('diff HEAD~1 --name-only --relative --diff-filter=ACM'); // shallow fallback
1344
+ if (out === null) return null;
1345
+ const files = out.split('\n').map((s) => s.trim()).filter(Boolean);
1346
+ return files.filter((rel) => ARTIFACT_MATCHERS.some((m) => m.re.test(rel)));
1347
+ }
1348
+ const GH_LEVEL = { CRITICAL: 'failure', HIGH: 'failure', MEDIUM: 'warning', LOW: 'notice', INFO: 'notice' };
1349
+
1350
+ async function cmdPr(flags, positional) {
1351
+ // Scaffold the workflow and exit.
1352
+ if (flags.init) {
1353
+ const wf = path.resolve('.github/workflows/shomra.yml');
1354
+ if (fs.existsSync(wf) && !flags.force) { console.error(red('✗') + ` ${path.relative(process.cwd(), wf)} exists. Use ${bold('--force')}.`); process.exit(1); }
1355
+ fs.mkdirSync(path.dirname(wf), { recursive: true });
1356
+ fs.writeFileSync(wf, PR_WORKFLOW);
1357
+ console.log(`\n ${green('✓ Wrote')} ${bold('.github/workflows/shomra.yml')} ${dim('— commit it; PRs will get an inline Shomra review.')}`);
1358
+ console.log(dim(' Optional: add ') + bold('SHOMRA_API_KEY') + dim(' as a repo secret to also apply org policy.') + '\n');
1359
+ return;
1360
+ }
1361
+
1362
+ const cfg = loadConfig();
1363
+ const { apiKey, url } = resolveSettings(cfg);
1364
+ const ev = readGithubEvent();
1365
+ const repo = flags.repo || process.env.GITHUB_REPOSITORY;
1366
+ const token = flags.token || process.env.SHOMRA_GH_TOKEN || process.env.GITHUB_TOKEN;
1367
+ const base = flags.base || process.env.GITHUB_BASE_REF || ev.pull_request?.base?.ref || 'main';
1368
+ const headSha = flags.sha || ev.pull_request?.head?.sha || process.env.GITHUB_SHA || (() => { try { return execSync('git rev-parse HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch { return null; } })();
1369
+ const prNumber = flags.pr || ev.pull_request?.number || (String(process.env.GITHUB_REF || '').match(/refs\/pull\/(\d+)\//) || [])[1];
1370
+ const root = path.resolve(flags.path || '.');
1371
+ const dryRun = !!flags['dry-run'];
1372
+
1373
+ 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); }
1374
+ if (!token && !dryRun) { console.error(red('✗') + ' No GitHub token. Set GITHUB_TOKEN (CI) or --token, or preview with --dry-run.'); process.exit(1); }
1375
+
1376
+ // Gate the CHANGED artifacts (fall back to the whole tree if the diff won't resolve).
1377
+ const changed = gitChangedVsBase(root, base);
1378
+ const all = walkArtifacts(root);
1379
+ const artifacts = changed === null ? all : all.filter((a) => new Set(changed).has(a.rel));
1380
+ const env = detectEnv();
1381
+
1382
+ if (!artifacts.length) {
1383
+ if (!flags.json) console.log(green('\n ✓ No AI artifacts changed in this PR.\n'));
1384
+ 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)));
1385
+ return;
1386
+ }
1387
+
1388
+ const { results, blocked, flagged, suppressed } = await gateArtifactList(artifacts, { apiKey, url, env, flags: { ...flags, json: true }, root });
1389
+
1390
+ // Build inline annotations (GitHub caps a check-run at 50 per request).
1391
+ const annotations = [];
1392
+ for (const r of results) {
1393
+ for (const f of r.findings || []) {
1394
+ annotations.push({
1395
+ path: f.file || r.path,
1396
+ start_line: f.line || 1,
1397
+ end_line: f.line || 1,
1398
+ annotation_level: GH_LEVEL[f.severity] || 'warning',
1399
+ title: `${f.severity}: ${r.kind}`,
1400
+ message: [f.title, f.remediationText ? `Fix: ${f.remediationText}` : '', `Run \`shomra fix ${r.path}\` to remediate.`].filter(Boolean).join('\n'),
1401
+ });
1402
+ }
1403
+ }
1404
+ const shown = annotations.slice(0, 50);
1405
+ const conclusion = blocked ? 'failure' : flagged ? (flags.strict ? 'failure' : 'neutral') : 'success';
1406
+ const summary = [
1407
+ blocked ? `**${blocked} blocked**` : flagged ? `**${flagged} flagged**` : '**All clear**',
1408
+ `· ${results.length} artifact(s) changed · ${annotations.length} finding(s)${suppressed ? ` · ${suppressed} suppressed` : ''}`,
1409
+ '',
1410
+ '| Artifact | Kind | Verdict | Findings |',
1411
+ '| --- | --- | --- | --- |',
1412
+ ...results.map((r) => `| \`${r.path}\` | ${r.kind} | ${r.decision} | ${(r.findings || []).length} |`),
1413
+ annotations.length > 50 ? `\n_Showing first 50 of ${annotations.length} annotations._` : '',
1414
+ ].join('\n');
1415
+ const checkRun = {
1416
+ name: 'Shomra AI Security', head_sha: headSha, status: 'completed', conclusion,
1417
+ output: { title: `${blocked ? blocked + ' blocked' : flagged ? flagged + ' flagged' : 'Clean'} — ${results.length} changed artifact(s)`, summary, annotations: shown },
1418
+ };
1419
+
1420
+ if (dryRun || flags.json) {
1421
+ console.log(JSON.stringify({ repo, prNumber: prNumber ?? null, headSha, base, conclusion, artifacts: results.length, findings: annotations.length, checkRun: dryRun ? checkRun : undefined }, null, 2));
1422
+ } else {
1423
+ console.log(bold(cyan('\n Shomra pr')) + dim(` — ${repo} #${prNumber ?? '?'} · ${results.length} changed artifact(s) · ${annotations.length} finding(s)`));
1424
+ }
1425
+
1426
+ if (token && !dryRun) {
1427
+ try {
1428
+ const run = await githubApi(token, 'POST', `/repos/${repo}/check-runs`, checkRun);
1429
+ if (!flags.json) console.log(` ${conclusion === 'failure' ? red('✗') : conclusion === 'neutral' ? yellow('⚠') : green('✓')} Check run posted → ${dim(run.html_url || '')}`);
1430
+ } catch (e) {
1431
+ console.error(` ${yellow('⚠')} ${dim('could not post check-run: ' + e.message)}`);
1432
+ }
1433
+ }
1434
+
1435
+ if (blocked) process.exitCode = 1;
1436
+ else if (flagged && flags.strict) process.exitCode = 2;
1437
+ }
1438
+
1439
+ // ── shomra fix: remediate an AI artifact in place ────────────────
1440
+ //
1441
+ // shomra fix .mcp.json # preview the AI fix (unified diff), don't write
1442
+ // shomra fix .mcp.json --apply # write the fix back to the file
1443
+ // shomra fix .cursorrules --json # machine-readable
1444
+ //
1445
+ // The fix is generated on the Shomra platform (org AI key) and applied to your
1446
+ // LOCAL working tree — enrollment is required. Degrades to printing the
1447
+ // deterministic remediation guidance when the server has no AI configured.
1448
+ async function cmdFix(flags, positional) {
1449
+ const file = positional[0];
1450
+ if (!file) {
1451
+ console.error(red('✗') + ' Usage: ' + bold('shomra fix <file> [--apply] [--kind mcp|skill|command|subagent|hook|rules] [--json]'));
1452
+ process.exit(1);
1453
+ }
1454
+ const cfg = loadConfig();
1455
+ const { apiKey, url } = resolveSettings(cfg);
1456
+ if (!apiKey) {
1457
+ console.error('\n' + red('✗') + ' ' + bold('shomra fix') + ' needs enrollment — the fix is generated on the platform with your org AI key.');
1458
+ console.error(' ' + dim('Run ') + bold('shomra init --key shm_live_…') + dim(', or apply the guidance from ') + bold('shomra check') + dim(' by hand.\n'));
1459
+ process.exit(1);
1460
+ }
1461
+ let target = path.resolve(String(file));
1462
+ if (fs.existsSync(target) && fs.statSync(target).isDirectory()) {
1463
+ const skillMd = path.join(target, 'SKILL.md');
1464
+ if (!fs.existsSync(skillMd)) {
1465
+ console.error(red('✗') + ` ${file} is a directory with no SKILL.md — point at a file instead.`);
1466
+ process.exit(1);
1467
+ }
1468
+ target = skillMd;
1469
+ }
1470
+ if (!fs.existsSync(target)) {
1471
+ console.error(red('✗') + ` File not found: ${file}`);
1472
+ process.exit(1);
1473
+ }
1474
+ await fixOneFile(target, { apiKey, url, flags });
1475
+ }
1476
+
1477
+ // Generate (and, with --apply, write) a fix for ONE file. Returns true when a
1478
+ // fix was produced (previewed or applied), false otherwise. Reused by `check --fix`.
1479
+ async function fixOneFile(target, { apiKey, url, flags }) {
1480
+ const rel = path.relative(process.cwd(), target).split(path.sep).join('/');
1481
+ let content;
1482
+ try {
1483
+ content = fs.readFileSync(target, 'utf8');
1484
+ } catch (e) {
1485
+ if (!flags.json) console.error(` ${red('✗')} cannot read ${rel}: ${e.message}`);
1486
+ return false;
1487
+ }
1488
+ const kind = flags.kind && GATE_KINDS.includes(String(flags.kind)) ? String(flags.kind) : undefined;
1489
+
1490
+ let res;
1491
+ try {
1492
+ if (!flags.json && !flags.quiet) process.stdout.write(dim(` Generating fix for ${rel}… `));
1493
+ res = await api(url, apiKey, '/gate/fix', {
1494
+ ...(kind ? { kind } : {}),
1495
+ path: rel,
1496
+ name: rel.split('/').pop(),
1497
+ content,
1498
+ });
1499
+ if (!flags.json && !flags.quiet) console.log('');
1500
+ } catch (e) {
1501
+ if (flags.json) console.log(JSON.stringify({ path: rel, canFix: false, error: e.message }, null, 2));
1502
+ else console.error(` ${red('✗')} ${e.message}`);
1503
+ return false;
1504
+ }
1505
+
1506
+ if (flags.json) console.log(JSON.stringify({ path: rel, ...res }, null, 2));
1507
+
1508
+ if (!res.canFix) {
1509
+ if (!flags.json) {
1510
+ if (res.reason === 'clean') console.log(` ${green('✓')} ${dim(rel + ' — nothing to fix.')}`);
1511
+ else if (res.reason === 'ai-disabled') {
1512
+ console.log(` ${yellow('⚠')} ${res.message}`);
1513
+ for (const g of res.guidance || []) {
1514
+ console.log(` ${SEV_COLOR[g.severity](String(g.severity).padEnd(8))} ${g.title}`);
1515
+ if (g.remediationText) console.log(` ${dim('fix: ' + g.remediationText)}`);
1516
+ }
1517
+ console.log('');
1518
+ } else console.log(` ${yellow('⚠')} ${dim(rel + ' — ')}${res.message || 'no fix produced.'}`);
1519
+ }
1520
+ return false;
1521
+ }
1522
+
1523
+ if (!flags.json) {
1524
+ printDiff(res.diff);
1525
+ if (res.explanation) console.log(` ${dim(res.explanation)}`);
1526
+ const conf = res.confidence != null ? ` ${dim('confidence ' + Math.round(res.confidence * 100) + '%')}` : '';
1527
+ if (conf) console.log(conf);
1528
+ console.log('');
1529
+ }
1530
+
1531
+ const apply = flags.apply || flags.write || flags.yes;
1532
+ if (!apply) {
1533
+ if (!flags.json) console.log(` ${dim('Preview only — re-run with')} ${bold('--apply')} ${dim('to write this fix to ' + rel + '.')}\n`);
1534
+ return true;
1535
+ }
1536
+ try {
1537
+ fs.writeFileSync(target, res.fixedContent, 'utf8');
1538
+ if (!flags.json && !flags.quiet) {
1539
+ console.log(` ${green('✓ Applied')} ${dim('→ ' + rel + ' (' + (res.findingCount || (res.findings || []).length) + ' finding(s) addressed)')}\n`);
1540
+ }
1541
+ return true;
1542
+ } catch (e) {
1543
+ if (!flags.json) console.error(` ${red('✗')} could not write ${rel}: ${e.message}`);
1544
+ return false;
1545
+ }
1546
+ }
1547
+
1548
+ // Colorized unified-diff printer (green add / red remove / cyan hunk header).
1549
+ function printDiff(diff) {
1550
+ if (!diff) return;
1551
+ for (const line of String(diff).split('\n')) {
1552
+ if (line.startsWith('+') && !line.startsWith('+++')) console.log(' ' + green(line));
1553
+ else if (line.startsWith('-') && !line.startsWith('---')) console.log(' ' + red(line));
1554
+ else if (line.startsWith('@@')) console.log(' ' + cyan(line));
1555
+ else console.log(' ' + dim(line));
1556
+ }
1557
+ }
1558
+
1559
+ // ── shomra why: understand a finding (the dev shape of "investigate") ──
1560
+ //
1561
+ // shomra why .mcp.json # plain-English why each finding matters + FP read
1562
+ // shomra why CLAUDE.md --json
1563
+ //
1564
+ // AI-distilled when enrolled (per-finding why + one-line exploit + true/false-
1565
+ // positive call); offline it prints the on-machine findings + their fixes.
1566
+ async function cmdWhy(flags, positional) {
1567
+ const file = positional[0];
1568
+ if (!file) {
1569
+ console.error(red('✗') + ' Usage: ' + bold('shomra why <file> [--kind mcp|skill|command|subagent|hook|rules] [--json]'));
1570
+ process.exit(1);
1571
+ }
1572
+ let target = path.resolve(String(file));
1573
+ if (fs.existsSync(target) && fs.statSync(target).isDirectory()) {
1574
+ const skillMd = path.join(target, 'SKILL.md');
1575
+ if (!fs.existsSync(skillMd)) {
1576
+ console.error(red('✗') + ` ${file} is a directory with no SKILL.md — point at a file instead.`);
1577
+ process.exit(1);
1578
+ }
1579
+ target = skillMd;
1580
+ }
1581
+ if (!fs.existsSync(target)) {
1582
+ console.error(red('✗') + ` File not found: ${file}`);
1583
+ process.exit(1);
1584
+ }
1585
+ const content = fs.readFileSync(target, 'utf8');
1586
+ const rel = path.relative(process.cwd(), target).split(path.sep).join('/');
1587
+ const kind = flags.kind && GATE_KINDS.includes(String(flags.kind)) ? String(flags.kind) : undefined;
1588
+
1589
+ const cfg = loadConfig();
1590
+ const { apiKey, url } = resolveSettings(cfg);
1591
+
1592
+ // Enrolled → AI-distilled explanation. Offline (or backend down) → local
1593
+ // findings + their fixes, so `why` always answers something.
1594
+ if (apiKey) {
1595
+ try {
1596
+ if (!flags.json) process.stdout.write(dim(` Explaining ${rel}… `));
1597
+ const res = await api(url, apiKey, '/gate/explain', { ...(kind ? { kind } : {}), path: rel, name: rel.split('/').pop(), content });
1598
+ if (!flags.json) console.log('');
1599
+ if (flags.json) console.log(JSON.stringify({ path: rel, ...res }, null, 2));
1600
+ else printWhy(res);
1601
+ return;
1602
+ } catch (e) {
1603
+ if (!flags.json) console.log(yellow('backend unavailable') + dim(` — on-machine explanation (${e.message})`));
1604
+ // fall through to the local rationale
1605
+ }
1606
+ }
1607
+ whyLocal(content, kind, rel, flags);
1608
+ }
1609
+
1610
+ function whyLocal(content, kind, rel, flags) {
1611
+ const local = localGate(content, { kind, path: rel });
1612
+ if (flags.json) {
1613
+ console.log(JSON.stringify({ path: rel, source: 'local', ...local }, null, 2));
1614
+ return;
1615
+ }
1616
+ console.log(bold(cyan('\n Shomra why')) + dim(` — ${rel} · on-machine`));
1617
+ if (!local.findings.length) {
1618
+ console.log(green('\n ✓ No findings — nothing to explain.\n'));
1619
+ return;
1620
+ }
1621
+ for (const f of local.findings) {
1622
+ const at = f.line ? dim(` (line ${f.line})`) : '';
1623
+ console.log(`\n ${SEV_COLOR[f.severity]('●')} ${SEV_COLOR[f.severity](f.severity)} ${bold(f.title)}${at}`);
1624
+ if (f.remediationText) console.log(` ${dim('fix: ' + f.remediationText)}`);
1625
+ }
1626
+ console.log(dim('\n Enroll (') + bold('shomra init') + dim(') for an AI-distilled why + false-positive read.\n'));
1627
+ }
1628
+
1629
+ function printWhy(res) {
1630
+ console.log(bold(cyan('\n Shomra why')) + dim(` — ${res.path}${res.aiEnabled ? '' : ' · rule rationale (AI off)'}`));
1631
+ if (res.summary) console.log(' ' + res.summary);
1632
+ if (!res.findings || !res.findings.length) {
1633
+ console.log(green('\n ✓ Nothing to explain.\n'));
1634
+ return;
1635
+ }
1636
+ for (const f of res.findings) {
1637
+ const at = f.line ? dim(` (line ${f.line})`) : '';
1638
+ const fp = f.likelyFalsePositive ? yellow(' · likely false positive') : '';
1639
+ console.log(`\n ${SEV_COLOR[f.severity]('●')} ${SEV_COLOR[f.severity](f.severity)} ${bold(f.title)}${at}${fp}`);
1640
+ if (f.why) console.log(` ${f.why}`);
1641
+ if (f.exploit) console.log(` ${dim('exploit: ' + f.exploit)}`);
1642
+ if (f.assessment) console.log(` ${dim(f.assessment)}`);
1643
+ if (f.remediationText) console.log(` ${dim('fix: ' + f.remediationText)}`);
1644
+ }
1645
+ console.log('');
1646
+ }
1647
+
1648
+ // ── shomra install-precommit: gate staged AI artifacts at commit time ──
1649
+ //
1650
+ // shomra install-precommit [dir] [--force]
1651
+ //
1652
+ // Writes a .git/hooks/pre-commit that runs `shomra check --staged`, so a risky
1653
+ // MCP config / skill / rules file is caught before it commits. A BLOCK stops the
1654
+ // commit; flags warn but don't. Override once with `git commit --no-verify`.
1655
+ async function cmdInstallPrecommit(flags, positional) {
1656
+ const root = path.resolve(positional[0] || '.');
1657
+ const hooksDir = gitHooksDir(root);
1658
+ if (!hooksDir) {
1659
+ console.error(red('✗') + ' Not a git repository (or git unavailable). cd into your repo first.');
1660
+ process.exit(1);
1661
+ }
1662
+ const hookPath = path.join(hooksDir, 'pre-commit');
1663
+ const marker = 'shomra check --staged';
1664
+ const managed = [
1665
+ '#!/bin/sh',
1666
+ '# Shomra — block staged AI artifacts that fail the gate before they land.',
1667
+ '# Managed by `shomra install-precommit`. Delete this file to uninstall.',
1668
+ 'command -v shomra >/dev/null 2>&1 || { echo "shomra not on PATH — skipping AI-artifact gate"; exit 0; }',
1669
+ 'shomra check --staged',
1670
+ 'if [ "$?" -eq 1 ]; then',
1671
+ ' echo "✗ Shomra blocked a staged AI artifact — run: shomra fix <file> --apply (or: git commit --no-verify to override)"',
1672
+ ' exit 1',
1673
+ 'fi',
1674
+ 'exit 0',
1675
+ '',
1676
+ ].join('\n');
1677
+
1678
+ let existing = null;
1679
+ try {
1680
+ existing = fs.readFileSync(hookPath, 'utf8');
1681
+ } catch {}
1682
+
1683
+ if (existing && existing.includes(marker) && !flags.force) {
1684
+ console.log(green(' ✓') + ' Shomra pre-commit hook already installed ' + dim('→ ' + hookPath));
1685
+ return;
1686
+ }
1687
+ if (existing && !existing.includes(marker) && !flags.force) {
1688
+ console.log('\n ' + yellow('⚠') + ' A pre-commit hook already exists ' + dim('→ ' + hookPath));
1689
+ console.log(' Add this line to it, or re-run with ' + bold('--force') + ' to replace it (a backup is kept):');
1690
+ console.log(' ' + bold(marker) + '\n');
1691
+ return;
1692
+ }
1693
+ if (existing && flags.force) {
1694
+ try {
1695
+ fs.writeFileSync(hookPath + '.bak', existing);
1696
+ console.log(dim(' Backed up existing hook → ' + path.basename(hookPath) + '.bak'));
1697
+ } catch {}
1698
+ }
1699
+ fs.writeFileSync(hookPath, managed, 'utf8');
1700
+ try {
1701
+ fs.chmodSync(hookPath, 0o755);
1702
+ } catch {}
1703
+ console.log('\n ' + green('✓ Installed') + ' Shomra pre-commit hook ' + dim('→ ' + hookPath));
1704
+ console.log(dim(' Staged AI artifacts are now gated on every commit. Override once with ') + bold('git commit --no-verify') + dim('.\n'));
1705
+ }
1706
+
1707
+ // Resolve the repo's hooks dir (honours core.hooksPath / worktrees), creating it.
1708
+ function gitHooksDir(root) {
1709
+ try {
1710
+ const dir = execSync('git rev-parse --git-path hooks', { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000 }).toString().trim();
1711
+ if (!dir) return null;
1712
+ const abs = path.isAbsolute(dir) ? dir : path.join(root, dir);
1713
+ fs.mkdirSync(abs, { recursive: true });
1714
+ return abs;
1715
+ } catch {
1716
+ return null;
1717
+ }
1718
+ }
1719
+
1720
+ // ── workspace ZIP scan: static-analyze an archive of AI artifacts ────
1721
+ //
1722
+ // shomra scan-zip <workspace.zip> [--project <id>] [--json]
1723
+ //
1724
+ // Uploads the archive to the platform's Workspace Scan (static analysis only —
1725
+ // nothing in the archive is executed) and prints the per-kind report: Skills,
1726
+ // slash commands, subagents, hooks, MCP configs, rules files, secret files.
1727
+ // Exit codes: 0 = PASS/REVIEW, 2 = FAIL.
1728
+
1729
+ async function cmdScanZip(flags, positional) {
1730
+ const cfg = loadConfig();
1731
+ const { apiKey, url } = resolveSettings(cfg);
1732
+ if (!apiKey) {
1733
+ console.error('\n' + red('✗') + ' Not configured. Run ' + bold('shomra init --key shm_live_…') + ' first.');
1734
+ process.exit(1);
1735
+ }
1736
+ const file = positional[0];
1737
+ if (!file) {
1738
+ console.error(red('✗') + ' Usage: ' + bold('shomra scan-zip <workspace.zip> [--project <id>] [--json]'));
1739
+ process.exit(1);
1740
+ }
1741
+ const target = path.resolve(String(file));
1742
+ if (!fs.existsSync(target) || !fs.statSync(target).isFile()) {
1743
+ console.error(red('✗') + ` File not found: ${file}`);
1744
+ process.exit(1);
1745
+ }
1746
+ if (!/\.zip$/i.test(target)) {
1747
+ console.error(red('✗') + ` ${file} is not a .zip archive.`);
1748
+ process.exit(1);
1749
+ }
1750
+
1751
+ const buf = fs.readFileSync(target);
1752
+ const form = new FormData();
1753
+ form.append('file', new Blob([buf], { type: 'application/zip' }), path.basename(target));
1754
+ form.append('actor', `${os.hostname()}/${os.userInfo().username}`);
1755
+ if (flags.project) form.append('projectId', String(flags.project));
1756
+
1757
+ if (!flags.json) process.stdout.write(dim('\n Uploading to Workspace Scan… '));
1758
+ let res;
1759
+ try {
1760
+ const r = await fetch(`${url}/bundle/agent-scan`, {
1761
+ method: 'POST',
1762
+ headers: { 'X-Shomra-Key': apiKey, Connection: 'close' },
1763
+ body: form,
1764
+ });
1765
+ const text = await r.text();
1766
+ let json;
1767
+ try {
1768
+ json = JSON.parse(text);
1769
+ } catch {
1770
+ json = { raw: text };
1771
+ }
1772
+ if (!r.ok) {
1773
+ const msg = json?.message || json?.raw || r.statusText;
1774
+ throw new Error(`${r.status} ${Array.isArray(msg) ? msg.join(', ') : msg}`);
1775
+ }
1776
+ res = json;
1777
+ } catch (e) {
1778
+ if (!flags.json) console.log(red('failed'));
1779
+ console.error(` ${red('✗')} ${e.message}\n`);
1780
+ process.exit(1);
1781
+ }
1782
+ if (!flags.json) console.log(green('done'));
1783
+
1784
+ if (flags.json) {
1785
+ console.log(JSON.stringify(res, null, 2));
1786
+ } else {
1787
+ const vc = VERDICT_COLOR[res.verdict] || gray;
1788
+ console.log(`\n ${bold(res.filename)} ${dim(`· ${res.fileCount} files · ${res.artifactCount} AI artifact(s)`)}`);
1789
+ console.log(` ${vc('●')} ${vc(bold(res.verdict))} ${dim(`risk ${res.riskScore}/100 · ${res.findingCount} finding(s) · ${res.criticalCount} critical · ${res.highCount} high`)}`);
1790
+ if (res.policyDecision && res.policyDecision !== 'ALLOW') {
1791
+ const pc = res.policyDecision === 'BLOCK' ? red : yellow;
1792
+ const which = (res.policyHits || []).map((h) => h.policy).slice(0, 3).join(', ');
1793
+ console.log(` ${pc('▎')} ${pc('org policy: ' + res.policyDecision)}${which ? dim(' — ' + which) : ''}`);
1794
+ }
1795
+ for (const g of res.groups || []) {
1796
+ if (!g.count) continue;
1797
+ console.log(`\n ${bold(g.kind.replace(/_/g, ' ').toLowerCase())} ${dim(`(${g.count})`)}`);
1798
+ for (const a of g.artifacts || []) {
1799
+ const avc = VERDICT_COLOR[a.verdict] || gray;
1800
+ console.log(` ${avc('●')} ${bold(a.name)} ${dim(a.path)} ${avc(a.verdict)} ${dim('risk ' + a.riskScore)}`);
1801
+ for (const f of (a.findings || []).filter((x) => x.severity !== 'INFO')) {
1802
+ console.log(` ${SEV_COLOR[f.severity](String(f.severity).padEnd(8))} ${f.title}`);
1803
+ }
1804
+ }
1805
+ }
1806
+ console.log(
1807
+ '\n ' +
1808
+ (res.verdict === 'FAIL'
1809
+ ? red('✗ Do not install this workspace unreviewed.')
1810
+ : res.verdict === 'REVIEW'
1811
+ ? yellow('⚠ Review the findings above before trusting this workspace.')
1812
+ : green('✓ Clean.')) +
1813
+ dim(' Full report in the Shomra dashboard → Workspace Scan.\n'),
1814
+ );
1815
+ }
1816
+ // Org policy takes precedence for CI: a BLOCK fails the build (exit 1), above
1817
+ // the severity-only FAIL (exit 2). A policy FLAG fails only with --strict.
1818
+ if (res.policyDecision === 'BLOCK') process.exitCode = 1;
1819
+ else if (res.verdict === 'FAIL') process.exitCode = 2;
1820
+ else if (res.policyDecision === 'FLAG' && flags.strict) process.exitCode = 2;
1821
+ }
1822
+
1823
+ // ── model SAST scan: analyze a public AI model's source code ─────────
1824
+ //
1825
+ // shomra model-scan <hf-url | owner/model | github-url> [--project <id>] [--json]
1826
+ //
1827
+ // Runs the platform's MODEL engine: pulls the model's source (Hugging Face Hub
1828
+ // API or a shallow GitHub clone — never the weights) and runs SAST over its
1829
+ // .py files + config.json, plus provenance/weight/card checks. Prints the
1830
+ // per-asset findings with rule id, file:line and code snippet. Nothing is
1831
+ // executed. Exit codes: 0 = PASS/REVIEW, 2 = FAIL.
1832
+
1833
+ async function cmdModelScan(flags, positional) {
1834
+ const cfg = loadConfig();
1835
+ const { apiKey, url } = resolveSettings(cfg);
1836
+ if (!apiKey) {
1837
+ console.error('\n' + red('✗') + ' Not configured. Run ' + bold('shomra init --key shm_live_…') + ' first.');
1838
+ process.exit(1);
1839
+ }
1840
+ const target = positional[0];
1841
+ if (!target) {
1842
+ console.error(red('✗') + ' Usage: ' + bold('shomra model-scan <hf-url | owner/model | github-url> [--project <id>] [--json]'));
1843
+ process.exit(1);
1844
+ }
1845
+
1846
+ process.stdout.write(dim(`\n Scanning ${target}… `));
1847
+ let res;
1848
+ try {
1849
+ res = await api(url, apiKey, '/projects/agent-model-scan', {
1850
+ target: String(target),
1851
+ actor: `${os.hostname()}/${os.userInfo().username}`,
1852
+ ...(flags.project ? { projectId: String(flags.project) } : {}),
1853
+ });
1854
+ } catch (e) {
1855
+ console.log(red('failed'));
1856
+ console.error(` ${red('✗')} ${e.message}\n`);
1857
+ process.exit(1);
1858
+ }
1859
+ console.log(green('done'));
1860
+
1861
+ if (flags.json) {
1862
+ console.log(JSON.stringify(res, null, 2));
1863
+ if (res.verdict === 'FAIL') process.exitCode = 2;
1864
+ return;
1865
+ }
1866
+
1867
+ const vc = VERDICT_COLOR[res.verdict] || gray;
1868
+ console.log(`\n ${bold(res.target || target)} ${dim(`· ${res.scanType} scan`)}`);
1869
+ console.log(
1870
+ ` ${vc('●')} ${vc(bold(res.verdict))} ${dim(
1871
+ `risk ${res.riskScore}/100 · ${res.vulnCount} finding(s) · ${res.criticalCount} critical · ${res.highCount} high`,
1872
+ )}`,
1873
+ );
1874
+
1875
+ for (const a of res.assets || []) {
1876
+ const vulns = (a.vulnerabilities || []).filter((v) => v.severity !== 'INFO');
1877
+ if (!vulns.length) continue;
1878
+ console.log(`\n ${bold(a.name)} ${dim(`(${a.assetType})`)}`);
1879
+ for (const v of vulns) {
1880
+ const ev = v.evidence && v.evidence.analysis === 'sast' ? v.evidence : null;
1881
+ console.log(` ${SEV_COLOR[v.severity](String(v.severity).padEnd(8))} ${v.title}`);
1882
+ if (ev) {
1883
+ console.log(` ${dim(`${ev.ruleId} · ${ev.file}:${ev.line} · sink ${ev.sink}${ev.source ? ' · source ' + ev.source : ''}`)}`);
1884
+ if (ev.snippet) console.log(` ${gray(ev.snippet)}`);
1885
+ }
1886
+ }
1887
+ }
1888
+
1889
+ // Safer, lower-risk alternatives in the same category — best-effort, only when
1890
+ // the scanned model is also in the public Model Security Index.
1891
+ if (res.verdict === 'FAIL' || res.verdict === 'REVIEW') {
1892
+ const mid = hfModelIdFromTarget(String(res.target || target));
1893
+ if (mid) {
1894
+ try {
1895
+ const look = await modelLookup(url, mid);
1896
+ if (look && Array.isArray(look.alternatives) && look.alternatives.length) {
1897
+ console.log('\n ' + bold('Safer alternatives') + dim(' (same category, lower risk):'));
1898
+ printAlternatives(look.alternatives, 'model', ' ');
1899
+ }
1900
+ } catch { /* index enrichment is best-effort — never fail the scan on it */ }
1901
+ }
1902
+ }
1903
+
1904
+ console.log(
1905
+ '\n ' +
1906
+ (res.verdict === 'FAIL'
1907
+ ? red('✗ Do not load this model unreviewed.')
1908
+ : res.verdict === 'REVIEW'
1909
+ ? yellow('⚠ Review the findings above before trusting this model.')
1910
+ : green('✓ No high-severity issues found.')) +
1911
+ dim(' Full report in the Shomra dashboard → Projects.\n'),
1912
+ );
1913
+
1914
+ if (res.verdict === 'FAIL') process.exitCode = 2;
1915
+ }
1916
+
1917
+ // Normalize a model-scan target (HF URL, owner/model, or github URL) to the
1918
+ // "owner/name" id the Model Security Index looks up by. Returns null when the
1919
+ // target isn't an HF-style id (e.g. a bare github repo we can't map).
1920
+ function hfModelIdFromTarget(target) {
1921
+ const t = String(target || '').trim();
1922
+ const hf = t.match(/huggingface\.co\/([^/\s?#]+\/[^/\s?#]+)/i);
1923
+ if (hf) return hf[1];
1924
+ if (/^[\w.-]+\/[\w.-]+$/.test(t) && !/github\.com/i.test(t)) return t; // owner/model
1925
+ return null;
1926
+ }
1927
+
1928
+ // ── memory integrity: scan + track persistent agent memory ──────────
1929
+ //
1930
+ // shomra memory-scan [path] [--scope project|user|global] [--writer AGENT|HUMAN|HOOK|TOOL] [--project <id>] [--json]
1931
+ //
1932
+ // Scans persistent agent-memory stores (MEMORY.md, .claude/memory/…, mem0 data)
1933
+ // AND rules/instruction files (CLAUDE.md, AGENTS.md, .cursorrules, …) for context
1934
+ // poisoning (OWASP ASI06) — injected standing directives, authority spoofing,
1935
+ // staged payloads, exfil sinks — and reports each write to the platform with
1936
+ // provenance so the integrity timeline, drift detection and rollback work. Rules
1937
+ // files are graded against an instruction baseline (their path decides the mode).
1938
+ // Point it at a repo/dir or a single file. Exit: 0 = clean/review, 2 = poisoned.
1939
+
1940
+ const MEMORY_MATCHERS = [
1941
+ /(^|\/)MEMOR(Y|IES)\.(md|json|jsonl|txt)$/i,
1942
+ /(^|\/)memor(y|ies)\/[^/]+\.(md|mdx|json|jsonl|txt|ya?ml)$/i,
1943
+ /(^|\/)\.(mem0|letta|memgpt)\/[^/]+\.(md|json|jsonl|txt)$/i,
1944
+ ];
1945
+
1946
+ // Rules / instruction files. These re-inject as high-authority trusted context
1947
+ // every session and are increasingly agent-mutable (Claude Code `#`/`/init`,
1948
+ // Cursor/Cline auto-rule writes), so they share memory's poisoning + drift
1949
+ // surface (OWASP ASI06). The platform grades them against an instruction
1950
+ // baseline — standing directives are legitimate; only hijack/conceal/exfil
1951
+ // phrasing is poison. Mirrors the backend detector's rules-file matching.
1952
+ const INSTRUCTION_MATCHERS = [
1953
+ /(^|\/)(CLAUDE|AGENTS?|GEMINI|CONVENTIONS)\.md$/i,
1954
+ /(^|\/)LLMS(-FULL)?\.txt$/i,
1955
+ /(^|\/)\.(cursorrules|windsurfrules|clinerules|aiderrules|continuerules|goosehints)$/i,
1956
+ /(^|\/)\.github\/copilot-instructions\.md$/i,
1957
+ /(^|\/)copilot-instructions\.md$/i,
1958
+ /(^|\/)\.cursor\/rules\/.+\.mdc$/i,
1959
+ /(^|\/)\.clinerules\/.+\.md$/i,
1960
+ ];
1961
+
1962
+ function isMemoryPath(p) {
1963
+ const rel = String(p || '').split(path.sep).join('/');
1964
+ return MEMORY_MATCHERS.some((re) => re.test(rel)) || INSTRUCTION_MATCHERS.some((re) => re.test(rel));
1965
+ }
1966
+
1967
+ function walkMemoryFiles(root) {
1968
+ const found = [];
1969
+ const stack = [root];
1970
+ while (stack.length) {
1971
+ const dir = stack.pop();
1972
+ let entries;
1973
+ try {
1974
+ entries = fs.readdirSync(dir, { withFileTypes: true });
1975
+ } catch {
1976
+ continue;
1977
+ }
1978
+ for (const ent of entries) {
1979
+ const full = path.join(dir, ent.name);
1980
+ if (ent.isDirectory()) {
1981
+ if (!SKIP_DIRS.has(ent.name)) stack.push(full);
1982
+ continue;
1983
+ }
1984
+ const rel = path.relative(root, full).split(path.sep).join('/');
1985
+ if (isMemoryPath(rel) || isMemoryPath(full)) found.push({ full, rel });
1986
+ }
1987
+ }
1988
+ return found;
1989
+ }
1990
+
1991
+ // Fire-and-forget provenance report of a memory write (used by the PreToolUse
1992
+ // hook). Best-effort, short-timeout, never affects the caller's flow.
1993
+ async function reportMemoryWrite(url, apiKey, body) {
1994
+ try {
1995
+ const ctrl = new AbortController();
1996
+ const timer = setTimeout(() => ctrl.abort(), guardTimeoutMs());
1997
+ await fetch(`${url}/memory/ingest`, {
1998
+ method: 'POST',
1999
+ headers: { 'Content-Type': 'application/json', 'X-Shomra-Key': apiKey, Connection: 'close' },
2000
+ body: JSON.stringify(body),
2001
+ signal: ctrl.signal,
2002
+ });
2003
+ clearTimeout(timer);
2004
+ } catch {
2005
+ /* memory tracking is best-effort — never disrupt the tool call */
2006
+ }
2007
+ }
2008
+
2009
+ async function cmdMemoryScan(flags, positional) {
2010
+ const cfg = loadConfig();
2011
+ const { apiKey, url } = resolveSettings(cfg);
2012
+ if (!apiKey) {
2013
+ console.error('\n' + red('✗') + ' Not configured. Run ' + bold('shomra init --key shm_live_…') + ' first.');
2014
+ process.exit(1);
2015
+ }
2016
+ const targetArg = positional[0] || '.';
2017
+ const target = path.resolve(String(targetArg));
2018
+ if (!fs.existsSync(target)) {
2019
+ console.error(red('✗') + ` Not found: ${targetArg}`);
2020
+ process.exit(1);
2021
+ }
2022
+ const files = fs.statSync(target).isDirectory()
2023
+ ? walkMemoryFiles(target)
2024
+ : [{ full: target, rel: path.basename(target) }];
2025
+
2026
+ if (!files.length) {
2027
+ if (flags.json) console.log(JSON.stringify({ scanned: 0, stores: [] }, null, 2));
2028
+ else console.log(dim(`\n No memory or rules files found under ${target}.\n (Looked for MEMORY.md, memory/ dirs, .mem0/.letta stores, and rules files: CLAUDE.md, AGENTS.md, .cursorrules, copilot-instructions.md, …)\n`));
2029
+ return;
2030
+ }
2031
+
2032
+ const scope = flags.scope ? String(flags.scope).toLowerCase() : undefined;
2033
+ const writer = flags.writer ? String(flags.writer).toUpperCase() : 'AGENT';
2034
+ const actor = `${os.hostname()}/${os.userInfo().username}`;
2035
+ console.log(bold(cyan('\n Shomra Memory Integrity')) + dim(` — scanning ${files.length} store${files.length > 1 ? 's' : ''}`));
2036
+
2037
+ let worst = 'PASS';
2038
+ const stores = [];
2039
+ for (const f of files) {
2040
+ let content;
2041
+ try {
2042
+ const stat = fs.statSync(f.full);
2043
+ if (stat.size > MAX_ARTIFACT_BYTES) {
2044
+ console.log(` ${gray('•')} ${dim(f.rel)} ${yellow('skipped (too large)')}`);
2045
+ continue;
2046
+ }
2047
+ content = fs.readFileSync(f.full, 'utf8');
2048
+ } catch {
2049
+ continue;
2050
+ }
2051
+ let res;
2052
+ try {
2053
+ res = await api(url, apiKey, '/memory/ingest', {
2054
+ scope,
2055
+ path: f.rel,
2056
+ name: path.basename(f.rel),
2057
+ content,
2058
+ writer,
2059
+ source: 'shomra memory-scan',
2060
+ actor,
2061
+ ...(flags.project ? { projectId: String(flags.project) } : {}),
2062
+ });
2063
+ } catch (e) {
2064
+ console.log(` ${red('✗')} ${f.rel} ${red('ingest error: ' + e.message)}`);
2065
+ continue;
2066
+ }
2067
+ const v = res?.store?.verdict || 'PASS';
2068
+ if (v === 'FAIL') worst = 'FAIL';
2069
+ else if (v === 'REVIEW' && worst !== 'FAIL') worst = 'REVIEW';
2070
+ stores.push({ path: f.rel, ...res });
2071
+
2072
+ const vc = VERDICT_COLOR[v] || gray;
2073
+ const poison = res?.store?.poisonScore ?? 0;
2074
+ const anom = res?.provenance?.anomalous;
2075
+ console.log(
2076
+ `\n ${vc('●')} ${bold(path.basename(f.rel))} ${dim(f.rel)} ${vc(v)} ${dim('poison ' + poison + '/100')}` +
2077
+ (res?.quarantined ? ' ' + red('QUARANTINED') : '') +
2078
+ (anom ? ' ' + red('OUT-OF-BAND WRITE') : ''),
2079
+ );
2080
+ for (const finding of (res?.analysis?.findings || []).filter((x) => x.severity !== 'INFO')) {
2081
+ console.log(` ${SEV_COLOR[finding.severity](String(finding.severity).padEnd(8))} ${finding.title}`);
2082
+ }
2083
+ if (anom) console.log(` ${red('provenance:')} ${dim(res.provenance.reason)}`);
2084
+ }
2085
+
2086
+ if (flags.json) {
2087
+ console.log(JSON.stringify({ scanned: stores.length, worst, stores }, null, 2));
2088
+ } else {
2089
+ console.log(
2090
+ '\n ' +
2091
+ (worst === 'FAIL'
2092
+ ? red('✗ Memory poisoning detected — roll back the affected stores.')
2093
+ : worst === 'REVIEW'
2094
+ ? yellow('⚠ Review the flagged memory before the agent reloads it.')
2095
+ : green('✓ No memory poisoning found.')) +
2096
+ dim(' Full timeline + rollback in the Shomra dashboard → Memory.\n'),
2097
+ );
2098
+ }
2099
+ if (worst === 'FAIL') process.exitCode = 2;
2100
+ }
2101
+
2102
+ // ── continuous agentic red-teaming: prove your guardrails still hold ────
2103
+ //
2104
+ // shomra redteam [--target llm-guard|model] [--scenarios goal-hijack,jailbreak]
2105
+ // [--min 80] [--fail-on-regression] [--project <id>] [--json]
2106
+ //
2107
+ // Replays the adversarial scenario library against your OWN LLM Guard (probe
2108
+ // mode — nothing is persisted as a real attack) or model, scores resilience,
2109
+ // and flags regressions vs the previous run. Great in CI: gate a merge/deploy
2110
+ // on `--min <resilience>` and/or `--fail-on-regression`. Exit: 0 = pass,
2111
+ // 2 = below the resilience floor or a regression appeared.
2112
+
2113
+ async function cmdRedteam(flags) {
2114
+ const cfg = loadConfig();
2115
+ const { apiKey, url } = resolveSettings(cfg);
2116
+ if (!apiKey) {
2117
+ console.error('\n' + red('✗') + ' Not configured. Run ' + bold('shomra init --key shm_live_…') + ' first.');
2118
+ process.exit(1);
2119
+ }
2120
+ const targetKind = flags.target === 'model' ? 'model' : 'llm-guard';
2121
+ const scenarioKeys = typeof flags.scenarios === 'string' ? flags.scenarios.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
2122
+
2123
+ process.stdout.write(dim(`\n Red-teaming your ${targetKind === 'model' ? 'model' : 'LLM Guard'}… `));
2124
+ let run;
2125
+ try {
2126
+ run = await api(url, apiKey, '/redteam/agent-run', {
2127
+ targetKind,
2128
+ ...(scenarioKeys ? { scenarioKeys } : {}),
2129
+ ...(flags.project ? { projectId: String(flags.project) } : {}),
2130
+ ...(flags.evolve ? { evolutionary: true } : flags.adaptive ? { adaptive: true } : {}),
2131
+ actor: `${os.hostname()}/${os.userInfo().username}`,
2132
+ });
2133
+ } catch (e) {
2134
+ console.log(red('failed'));
2135
+ console.error(` ${red('✗')} ${e.message}\n`);
2136
+ process.exit(1);
2137
+ }
2138
+ console.log(green('done'));
2139
+
2140
+ if (flags.json) {
2141
+ console.log(JSON.stringify(run, null, 2));
2142
+ } else {
2143
+ const rc = run.resilience >= 80 ? green : run.resilience >= 60 ? yellow : red;
2144
+ console.log(`\n ${bold(run.label)} ${dim(`· ${run.targetKind} · ${run.scenarioCount} scenarios · ${run.attemptCount} attempts`)}`);
2145
+ console.log(` ${rc('●')} Resilience ${rc(bold(run.resilience + '/100'))} ${dim(`· ${run.breachedCount} breached · ${run.blockedCount} blocked${run.regressedCount ? ' · ' : ''}`)}${run.regressedCount ? red(run.regressedCount + ' regressed') : ''}`);
2146
+ for (const r of (run.results || []).filter((x) => x.breached)) {
2147
+ console.log(` ${red('✗')} ${bold(r.title)} ${dim(r.technique)} ${r.regressed ? red('· REGRESSED') : ''}`);
2148
+ }
2149
+ const held = (run.results || []).filter((x) => !x.breached).length;
2150
+ if (held) console.log(` ${green('✓')} ${dim(`${held} scenario(s) held`)}`);
2151
+ console.log(
2152
+ '\n ' +
2153
+ (run.breachedCount === 0
2154
+ ? green('✓ All scenarios defended.')
2155
+ : yellow(`⚠ ${run.breachedCount} scenario(s) breached your defenses.`)) +
2156
+ dim(' Full report in the Shomra dashboard → Red Team.\n'),
2157
+ );
2158
+ }
2159
+
2160
+ // CI gate: fail on a resilience floor and/or any regression.
2161
+ const min = flags.min != null ? parseInt(flags.min, 10) : null;
2162
+ const belowFloor = Number.isFinite(min) && run.resilience < min;
2163
+ const regressed = flags['fail-on-regression'] && run.regressedCount > 0;
2164
+ if (belowFloor) console.error(red(` ✗ Resilience ${run.resilience} is below the required ${min}.`));
2165
+ if (regressed) console.error(red(` ✗ ${run.regressedCount} scenario(s) regressed since the last run.`));
2166
+ if (belowFloor || regressed) process.exitCode = 2;
2167
+ }
2168
+
2169
+ // ── adversary campaigns: autonomous multi-turn red-team operator ──────────
2170
+ //
2171
+ // shomra campaign [--objectives exfil-canary,tool-abuse] [--turns 6]
2172
+ // [--min 80] [--project <id>] [--json]
2173
+ //
2174
+ // Runs an AUTONOMOUS attacker that pursues a concrete goal (exfiltrate a secret,
2175
+ // trigger a dangerous tool, leak the system prompt, poison memory) over a
2176
+ // multi-turn conversation with an assistant sitting behind your OWN LLM Guard,
2177
+ // adapting each turn to how the guard and the assistant responded. A breach
2178
+ // needs the whole chain to fail — the guard allows the turn AND the assistant
2179
+ // complies — which single-prompt scans can't surface. Needs AI configured.
2180
+ // Exit: 0 = pass, 2 = below the resilience floor.
2181
+
2182
+ async function cmdCampaign(flags) {
2183
+ const cfg = loadConfig();
2184
+ const { apiKey, url } = resolveSettings(cfg);
2185
+ if (!apiKey) {
2186
+ console.error('\n' + red('✗') + ' Not configured. Run ' + bold('shomra init --key shm_live_…') + ' first.');
2187
+ process.exit(1);
2188
+ }
2189
+ const objectiveKeys = typeof flags.objectives === 'string' ? flags.objectives.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
2190
+ const turns = flags.turns != null ? parseInt(flags.turns, 10) : undefined;
2191
+
2192
+ process.stdout.write(dim('\n Running an autonomous adversary campaign against your assistant… '));
2193
+ let run;
2194
+ try {
2195
+ run = await api(url, apiKey, '/redteam/agent-campaign', {
2196
+ ...(objectiveKeys ? { objectiveKeys } : {}),
2197
+ ...(Number.isFinite(turns) ? { turns } : {}),
2198
+ ...(flags.project ? { projectId: String(flags.project) } : {}),
2199
+ actor: `${os.hostname()}/${os.userInfo().username}`,
2200
+ });
2201
+ } catch (e) {
2202
+ console.log(red('failed'));
2203
+ console.error(` ${red('✗')} ${e.message}\n`);
2204
+ process.exit(1);
2205
+ }
2206
+ console.log(green('done'));
2207
+
2208
+ if (flags.json) {
2209
+ console.log(JSON.stringify(run, null, 2));
2210
+ } else {
2211
+ const rc = run.resilience >= 80 ? green : run.resilience >= 60 ? yellow : red;
2212
+ console.log(`\n ${bold(run.label)} ${dim(`· ${run.scenarioCount} objective(s) · ${run.attemptCount} turns fired`)}`);
2213
+ console.log(` ${rc('●')} Resilience ${rc(bold(run.resilience + '/100'))} ${dim(`· ${run.breachedCount} objective(s) achieved · ${run.blockedCount} turn(s) blocked by the guard`)}`);
2214
+ for (const r of (run.results || []).filter((x) => x.breached)) {
2215
+ const bt = (r.evidenceJson?.outcomes || []).filter((o) => o.breached).map((o) => o.index + 1);
2216
+ const inTurns = bt.length ? Math.min(...bt) : r.attempts;
2217
+ console.log(` ${red('✗')} ${bold(r.title)} ${dim(`${r.technique} · achieved in ${inTurns} turn(s)`)}`);
2218
+ }
2219
+ const held = (run.results || []).filter((x) => !x.breached).length;
2220
+ if (held) console.log(` ${green('✓')} ${dim(`${held} objective(s) defended`)}`);
2221
+ console.log(
2222
+ '\n ' +
2223
+ (run.breachedCount === 0
2224
+ ? green('✓ Every objective was defended.')
2225
+ : yellow(`⚠ ${run.breachedCount} objective(s) achieved by the autonomous attacker.`)) +
2226
+ dim(' Full transcript in the Shomra dashboard → Red Team. Harden the guard against the winning turns.\n'),
2227
+ );
2228
+ }
2229
+
2230
+ const min = flags.min != null ? parseInt(flags.min, 10) : null;
2231
+ if (Number.isFinite(min) && run.resilience < min) {
2232
+ console.error(red(` ✗ Resilience ${run.resilience} is below the required ${min}.`));
2233
+ process.exitCode = 2;
2234
+ }
2235
+ }
2236
+
2237
+ // ── self-hardening flywheel: red-team → propose → verify → apply ──────────
2238
+ //
2239
+ // `shomra harden` closes the loop the red-team opens. It runs a red-team (or
2240
+ // reuses one with --run), asks the platform to propose high-precision detection
2241
+ // signatures for whatever breached, verifies each against a benign corpus (must
2242
+ // catch the attack AND cause zero false positives), and — with --apply — pushes
2243
+ // the survivors live as a SignaturePack (no redeploy) and re-runs to prove the
2244
+ // resilience lift. Great as a scheduled CI step after `shomra redteam`.
2245
+ async function cmdHarden(flags) {
2246
+ const cfg = loadConfig();
2247
+ const { apiKey, url } = resolveSettings(cfg);
2248
+ if (!apiKey) {
2249
+ console.error('\n' + red('✗') + ' Not configured. Run ' + bold('shomra init --key shm_live_…') + ' first.');
2250
+ process.exit(1);
2251
+ }
2252
+ const targetKind = flags.target === 'model' ? 'model' : 'llm-guard';
2253
+ const apply = !!flags.apply;
2254
+ const runId = flags.run ? String(flags.run) : undefined;
2255
+
2256
+ process.stdout.write(
2257
+ dim(`\n ${runId ? 'Hardening from run ' + runId : 'Red-teaming your ' + (targetKind === 'model' ? 'model' : 'LLM Guard') + ', then hardening'}… `),
2258
+ );
2259
+ let res;
2260
+ try {
2261
+ res = await api(url, apiKey, '/flywheel/agent-harden', {
2262
+ ...(runId ? { runId } : {}),
2263
+ targetKind,
2264
+ apply,
2265
+ actor: `${os.hostname()}/${os.userInfo().username}`,
2266
+ });
2267
+ } catch (e) {
2268
+ console.log(red('failed'));
2269
+ console.error(` ${red('✗')} ${e.message}\n`);
2270
+ process.exit(1);
2271
+ }
2272
+ console.log(green('done'));
2273
+
2274
+ if (flags.json) {
2275
+ console.log(JSON.stringify(res, null, 2));
2276
+ return;
2277
+ }
2278
+
2279
+ const sc = res.status === 'APPLIED' ? green : res.status === 'VERIFIED' ? cyan : res.status === 'REJECTED' ? yellow : red;
2280
+ console.log(`\n ${sc('●')} ${bold(res.status)} ${dim('· ' + (res.origin === 'ai' ? 'AI-generated' : 'mined') + ((res.techniques || []).length ? ' · ' + res.techniques.join(', ') : ''))}`);
2281
+ if (res.gapTotal) {
2282
+ console.log(` ${res.gapClosed === res.gapTotal ? green('✓') : yellow('◑')} Closes ${bold(res.gapClosed + '/' + res.gapTotal)} breaching attempts`);
2283
+ }
2284
+ console.log(
2285
+ ` ${green('✓')} ${bold(String(res.signatures))} signature(s) passed the FP-gate ` +
2286
+ dim(`· ${res.falsePositives} false positives across ${res.benignTested} benign samples`),
2287
+ );
2288
+ if (res.applied) {
2289
+ const lift = res.resilienceBefore != null && res.resilienceAfter != null ? `${res.resilienceBefore} → ${res.resilienceAfter}/100` : 'live';
2290
+ console.log(` ${green('✓')} Applied — signatures are ${bold('live')} with no redeploy. Resilience ${bold(lift)}`);
2291
+ } else if (res.status === 'VERIFIED') {
2292
+ console.log(` ${cyan('→')} Ready. Re-run with ${bold('--apply')} to push them live, or review in the dashboard → Self-Hardening.`);
2293
+ } else if (res.status === 'REJECTED') {
2294
+ console.log(` ${yellow('⚠')} No candidate was both effective and false-positive-free — nothing applied.`);
2295
+ }
2296
+ console.log(dim('\n Full detail in the Shomra dashboard → Self-Hardening.\n'));
2297
+ }
2298
+
2299
+ // ── agent identity: register a non-human principal ───────────────────────
2300
+ //
2301
+ // `shomra agent-identity register` mints this agent its OWN credential
2302
+ // (shm_agt_…) so the LLM proxy + runtime firewall can authenticate it as a
2303
+ // distinct principal and authorize every call against its capability policy.
2304
+ // Present the handle via SHOMRA_AGENT (or --agent-id); govern its capabilities,
2305
+ // approve break-glass requests and revoke it (a live kill-switch) in the
2306
+ // dashboard. Listing/governing is JWT-only (server.approve) — not exposed to a
2307
+ // machine key — so the CLI only self-registers.
2308
+ async function cmdAgentIdentity(flags, positional) {
2309
+ const sub = (positional[0] || 'register').toLowerCase();
2310
+ const cfg = loadConfig();
2311
+ const { apiKey, url } = resolveSettings(cfg);
2312
+ if (!apiKey) {
2313
+ console.error('\n' + red('✗') + ' Not configured. Run ' + bold('shomra init --key shm_live_…') + ' first.');
2314
+ process.exit(1);
2315
+ }
2316
+ if (sub !== 'register') {
2317
+ console.error(`\n ${red('✗')} Unknown subcommand "${sub}". Use: ${bold('shomra agent-identity register --name "…" --type coding-agent')}`);
2318
+ console.error(dim(' (List / govern / revoke identities in the dashboard → Agent Identities.)\n'));
2319
+ process.exit(1);
2320
+ }
2321
+ let res;
2322
+ try {
2323
+ res = await api(url, apiKey, '/agents/register', {
2324
+ name: flags.name ? String(flags.name) : undefined,
2325
+ slug: flags.slug ? String(flags.slug) : undefined,
2326
+ type: flags.type ? String(flags.type) : undefined,
2327
+ });
2328
+ } catch (e) {
2329
+ console.error(`\n ${red('✗')} ${e.message}\n`);
2330
+ process.exit(1);
2331
+ }
2332
+ if (flags.json) {
2333
+ console.log(JSON.stringify(res, null, 2));
2334
+ return;
2335
+ }
2336
+ console.log(`\n ${green('✓')} Registered agent identity ${bold(res.name)} ${dim('(' + res.slug + ' · ' + res.type + ')')}`);
2337
+ if (res.credential) {
2338
+ console.log(`\n ${bold('Credential')} ${dim('(shown once — store it securely):')}`);
2339
+ console.log(` ${cyan(res.credential)}`);
2340
+ }
2341
+ console.log(`\n Present this identity so every call is authorized as it:`);
2342
+ console.log(dim(` export SHOMRA_AGENT=${res.slug} # or use the credential above`));
2343
+ console.log(dim(` Then set its least-privilege capabilities in the dashboard → Agent Identities.\n`));
2344
+ }
2345
+
2346
+ /** The agent-identity handle to present as x-shomra-agent (distinct from the
2347
+ * coding-agent KIND resolved by resolveAgentFlag). From --agent-id or the
2348
+ * SHOMRA_AGENT env var; null when unset (unattributed). */
2349
+ function resolveAgentIdentityHandle(flags) {
2350
+ const v = (flags && flags['agent-id'] && String(flags['agent-id'])) || process.env.SHOMRA_AGENT || '';
2351
+ return v && String(v).trim() ? String(v).trim() : null;
2352
+ }
2353
+
2354
+ // ── runtime tool-call / tool-result firewall: multi-agent hook support ────
2355
+ //
2356
+ // `shomra tool-guard` / `shomra result-guard` are hook handlers that a coding
2357
+ // agent's OWN pre/post tool-call hook system invokes. Each agent below ships
2358
+ // a genuine *blocking* hook (verified against vendor docs as of 2026-07) with
2359
+ // its own config file, event names, and stdin/stdout contract:
2360
+ // claude — Claude Code PreToolUse/PostToolUse (.claude/settings.json)
2361
+ // codex — OpenAI Codex CLI, mirrors Claude's shape (.codex/hooks.json)
2362
+ // gemini — Gemini CLI BeforeTool/AfterTool (.gemini/settings.json)
2363
+ // cursor — Cursor beforeShellExecution/beforeMCPExecution/afterFileEdit/
2364
+ // afterMCPExecution (.cursor/hooks.json)
2365
+ // windsurf — Windsurf pre_run_command/pre_write_code/pre_mcp_tool_use (only
2366
+ // pre_* hooks can block; post_* are visibility-only)
2367
+ // (.windsurf/hooks.json)
2368
+ // copilot — GitHub Copilot CLI preToolUse/postToolUse (.github/hooks/*.json)
2369
+ // cline — Cline (VS Code) PreToolUse/PostToolUse, Claude-style grouped
2370
+ // hooks over Cline's tool names (execute_command/write_to_file/
2371
+ // use_mcp_tool) (.cline/hooks.json)
2372
+ // aider — Aider has NO pre-tool-call hook API (it is a terminal pair
2373
+ // programmer, not a tool-dispatching agent). Its correct control
2374
+ // point is the model call, so install-hook routes Aider through the
2375
+ // Shomra LLM Guard proxy instead of a tool hook (.aider.conf.yml).
2376
+ // `shomra install-hook --agent <name>` writes the right shape; the installed
2377
+ // hook command carries `--agent <name>` so tool-guard/result-guard know which
2378
+ // contract to speak at runtime. Default agent is `claude` (unqualified hooks
2379
+ // installed before multi-agent support existed still work unchanged).
2380
+ //
2381
+ // These hook systems are new and still moving fast — if a hook silently stops
2382
+ // firing after a CLI/extension update, check that agent's current docs before
2383
+ // assuming Shomra is broken; each adapter is isolated below so a schema tweak
2384
+ // is a small, local edit. Fail-OPEN by default (never break the session if
2385
+ // the backend is down) — set SHOMRA_GUARD_STRICT=1 to fail closed.
2386
+
2387
+ const AGENT_LABELS = {
2388
+ claude: 'Claude Code',
2389
+ cursor: 'Cursor',
2390
+ windsurf: 'Windsurf',
2391
+ gemini: 'Gemini CLI',
2392
+ codex: 'OpenAI Codex CLI',
2393
+ copilot: 'GitHub Copilot CLI',
2394
+ cline: 'Cline',
2395
+ aider: 'Aider',
2396
+ };
2397
+ const AGENT_KEYS = Object.keys(AGENT_LABELS);
2398
+
2399
+ // The proxy base Aider (and any OpenAI-API client) should point at so its model
2400
+ // traffic is screened by the Shomra LLM Guard. Overridable for a remote proxy.
2401
+ const LLM_PROXY_BASE = process.env.SHOMRA_LLM_PROXY_BASE || 'http://localhost:4141/llm/openai';
2402
+
2403
+ function readJsonFile(file) {
2404
+ if (!fs.existsSync(file)) return {};
2405
+ try {
2406
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
2407
+ } catch {
2408
+ console.error(red('✗') + ` ${file} is not valid JSON — fix or move it first.`);
2409
+ process.exit(1);
2410
+ }
2411
+ }
2412
+ // Dedupe check for the {matcher, hooks:[{command}]} grouped shape (Claude/Codex/Gemini).
2413
+ function hasGroupedHook(list, needle) {
2414
+ return Array.isArray(list) && list.some((g) => Array.isArray(g.hooks) && g.hooks.some((h) => String(h.command || '').includes(needle)));
2415
+ }
2416
+ // Dedupe check for the flat {command} array shape (Cursor/Windsurf).
2417
+ function hasFlatHook(list) {
2418
+ return Array.isArray(list) && list.some((h) => String(h.command || '').includes('shomra '));
2419
+ }
2420
+
2421
+ // Each installer merges Shomra's hook(s) into that agent's config file and
2422
+ // returns { file, changed }. Idempotent — re-running install-hook is a no-op
2423
+ // once installed.
2424
+ const AGENT_INSTALLERS = {
2425
+ claude(global) {
2426
+ const dir = global ? path.join(os.homedir(), '.claude') : path.join(process.cwd(), '.claude');
2427
+ const file = path.join(dir, 'settings.json');
2428
+ const settings = readJsonFile(file);
2429
+ settings.hooks = settings.hooks || {};
2430
+ const pre = (settings.hooks.PreToolUse = settings.hooks.PreToolUse || []);
2431
+ const post = (settings.hooks.PostToolUse = settings.hooks.PostToolUse || []);
2432
+ let changed = false;
2433
+ if (!hasGroupedHook(pre, 'shomra tool-guard')) {
2434
+ pre.push({ matcher: 'Bash|Write|Edit|MultiEdit|NotebookEdit|mcp__.*', hooks: [{ type: 'command', command: 'shomra tool-guard --agent claude' }] });
2435
+ changed = true;
2436
+ }
2437
+ if (!hasGroupedHook(post, 'shomra result-guard')) {
2438
+ post.push({ matcher: 'WebFetch|WebSearch|Read|NotebookRead|mcp__.*', hooks: [{ type: 'command', command: 'shomra result-guard --agent claude' }] });
2439
+ changed = true;
2440
+ }
2441
+ if (changed) {
2442
+ fs.mkdirSync(dir, { recursive: true });
2443
+ fs.writeFileSync(file, JSON.stringify(settings, null, 2));
2444
+ }
2445
+ return { file, changed };
2446
+ },
2447
+
2448
+ // Codex CLI's hook shape deliberately mirrors Claude Code's.
2449
+ codex(global) {
2450
+ const dir = global ? path.join(os.homedir(), '.codex') : path.join(process.cwd(), '.codex');
2451
+ const file = path.join(dir, 'hooks.json');
2452
+ const settings = readJsonFile(file);
2453
+ const pre = (settings.PreToolUse = settings.PreToolUse || []);
2454
+ const post = (settings.PostToolUse = settings.PostToolUse || []);
2455
+ let changed = false;
2456
+ if (!hasGroupedHook(pre, 'shomra tool-guard')) {
2457
+ pre.push({ matcher: 'Bash|Write|Edit|mcp__.*', hooks: [{ type: 'command', command: 'shomra tool-guard --agent codex' }] });
2458
+ changed = true;
2459
+ }
2460
+ if (!hasGroupedHook(post, 'shomra result-guard')) {
2461
+ post.push({ matcher: 'WebFetch|WebSearch|Read|mcp__.*', hooks: [{ type: 'command', command: 'shomra result-guard --agent codex' }] });
2462
+ changed = true;
2463
+ }
2464
+ if (changed) {
2465
+ fs.mkdirSync(dir, { recursive: true });
2466
+ fs.writeFileSync(file, JSON.stringify(settings, null, 2));
2467
+ }
2468
+ return { file, changed };
2469
+ },
2470
+
2471
+ // Gemini CLI's hooks live under settings.json's `hooks` key, BeforeTool/AfterTool.
2472
+ gemini(global) {
2473
+ const dir = global ? path.join(os.homedir(), '.gemini') : path.join(process.cwd(), '.gemini');
2474
+ const file = path.join(dir, 'settings.json');
2475
+ const settings = readJsonFile(file);
2476
+ settings.hooks = settings.hooks || {};
2477
+ const before = (settings.hooks.BeforeTool = settings.hooks.BeforeTool || []);
2478
+ const after = (settings.hooks.AfterTool = settings.hooks.AfterTool || []);
2479
+ let changed = false;
2480
+ if (!hasGroupedHook(before, 'shomra tool-guard')) {
2481
+ before.push({ matcher: '.*', hooks: [{ type: 'command', command: 'shomra tool-guard --agent gemini' }] });
2482
+ changed = true;
2483
+ }
2484
+ if (!hasGroupedHook(after, 'shomra result-guard')) {
2485
+ after.push({ matcher: '.*', hooks: [{ type: 'command', command: 'shomra result-guard --agent gemini' }] });
2486
+ changed = true;
2487
+ }
2488
+ if (changed) {
2489
+ fs.mkdirSync(dir, { recursive: true });
2490
+ fs.writeFileSync(file, JSON.stringify(settings, null, 2));
2491
+ }
2492
+ return { file, changed };
2493
+ },
2494
+
2495
+ // Cursor — one array per event name (no matcher regex). Pre-execution
2496
+ // events can block; the post-* equivalents are best-effort.
2497
+ cursor(global) {
2498
+ const dir = global ? path.join(os.homedir(), '.cursor') : path.join(process.cwd(), '.cursor');
2499
+ const file = path.join(dir, 'hooks.json');
2500
+ const cfg = readJsonFile(file);
2501
+ if (cfg.version === undefined) cfg.version = 1;
2502
+ cfg.hooks = cfg.hooks || {};
2503
+ let changed = false;
2504
+ const wire = (event, command) => {
2505
+ const list = (cfg.hooks[event] = cfg.hooks[event] || []);
2506
+ if (!hasFlatHook(list)) {
2507
+ list.push({ command });
2508
+ changed = true;
2509
+ }
2510
+ };
2511
+ wire('beforeShellExecution', 'shomra tool-guard --agent cursor');
2512
+ wire('beforeMCPExecution', 'shomra tool-guard --agent cursor');
2513
+ wire('afterFileEdit', 'shomra result-guard --agent cursor');
2514
+ wire('afterMCPExecution', 'shomra result-guard --agent cursor');
2515
+ if (changed) {
2516
+ fs.mkdirSync(dir, { recursive: true });
2517
+ fs.writeFileSync(file, JSON.stringify(cfg, null, 2));
2518
+ }
2519
+ return { file, changed };
2520
+ },
2521
+
2522
+ // Windsurf/Cascade — only pre_* hooks can block; post_* are logged for
2523
+ // Gate Activity visibility but cannot withhold a result.
2524
+ windsurf(global) {
2525
+ const dir = global ? path.join(os.homedir(), '.codeium', 'windsurf') : path.join(process.cwd(), '.windsurf');
2526
+ const file = path.join(dir, 'hooks.json');
2527
+ const cfg = readJsonFile(file);
2528
+ cfg.hooks = cfg.hooks || {};
2529
+ let changed = false;
2530
+ const wire = (event, command) => {
2531
+ const list = (cfg.hooks[event] = cfg.hooks[event] || []);
2532
+ if (!hasFlatHook(list)) {
2533
+ list.push({ command });
2534
+ changed = true;
2535
+ }
2536
+ };
2537
+ wire('pre_run_command', 'shomra tool-guard --agent windsurf');
2538
+ wire('pre_write_code', 'shomra tool-guard --agent windsurf');
2539
+ wire('pre_mcp_tool_use', 'shomra tool-guard --agent windsurf');
2540
+ wire('post_mcp_tool_use', 'shomra result-guard --agent windsurf');
2541
+ if (changed) {
2542
+ fs.mkdirSync(dir, { recursive: true });
2543
+ fs.writeFileSync(file, JSON.stringify(cfg, null, 2));
2544
+ }
2545
+ return { file, changed };
2546
+ },
2547
+
2548
+ // GitHub Copilot CLI reads a directory of hook definition files — Shomra
2549
+ // drops its own, so there's no merge-with-existing-content risk.
2550
+ copilot(global) {
2551
+ const dir = global ? path.join(os.homedir(), '.copilot', 'hooks') : path.join(process.cwd(), '.github', 'hooks');
2552
+ const file = path.join(dir, 'shomra.json');
2553
+ if (fs.existsSync(file)) return { file, changed: false };
2554
+ const cfg = {
2555
+ preToolUse: [{ command: 'shomra tool-guard --agent copilot' }],
2556
+ postToolUse: [{ command: 'shomra result-guard --agent copilot' }],
2557
+ };
2558
+ fs.mkdirSync(dir, { recursive: true });
2559
+ fs.writeFileSync(file, JSON.stringify(cfg, null, 2));
2560
+ return { file, changed: true };
2561
+ },
2562
+
2563
+ // Cline (VS Code) is tool-dispatching like Claude Code, so it gets a real
2564
+ // blocking pre/post hook in the same grouped shape. Matcher covers Cline's
2565
+ // tool vocabulary (execute_command/write_to_file/replace_in_file/use_mcp_tool),
2566
+ // all of which ToolGuardService already recognises.
2567
+ cline(global) {
2568
+ const dir = global ? path.join(os.homedir(), '.cline') : path.join(process.cwd(), '.cline');
2569
+ const file = path.join(dir, 'hooks.json');
2570
+ const settings = readJsonFile(file);
2571
+ settings.hooks = settings.hooks || {};
2572
+ const pre = (settings.hooks.PreToolUse = settings.hooks.PreToolUse || []);
2573
+ const post = (settings.hooks.PostToolUse = settings.hooks.PostToolUse || []);
2574
+ let changed = false;
2575
+ if (!hasGroupedHook(pre, 'shomra tool-guard')) {
2576
+ 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' }] });
2577
+ changed = true;
2578
+ }
2579
+ if (!hasGroupedHook(post, 'shomra result-guard')) {
2580
+ post.push({ matcher: 'read_file|web_fetch|use_mcp_tool', hooks: [{ type: 'command', command: 'shomra result-guard --agent cline' }] });
2581
+ changed = true;
2582
+ }
2583
+ if (changed) {
2584
+ fs.mkdirSync(dir, { recursive: true });
2585
+ fs.writeFileSync(file, JSON.stringify(settings, null, 2));
2586
+ }
2587
+ return { file, changed };
2588
+ },
2589
+
2590
+ // Aider has no per-tool hook to intercept — the meaningful control point is
2591
+ // its LLM call. Point Aider's OpenAI-compatible base URL at the Shomra LLM
2592
+ // Guard proxy so every request/response is screened (`shomra llm-proxy` must
2593
+ // be running). We append a Shomra block to .aider.conf.yml rather than parse
2594
+ // YAML, and never duplicate it.
2595
+ aider(global) {
2596
+ const file = global ? path.join(os.homedir(), '.aider.conf.yml') : path.join(process.cwd(), '.aider.conf.yml');
2597
+ let text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
2598
+ if (/#\s*shomra llm guard/i.test(text) || text.includes(LLM_PROXY_BASE)) {
2599
+ return { file, changed: false };
2600
+ }
2601
+ const block =
2602
+ `\n# --- shomra llm guard ---\n` +
2603
+ `# Routes Aider's model traffic through the Shomra LLM Guard proxy so every\n` +
2604
+ `# prompt/response is policy-screened. Requires: shomra llm-proxy (running).\n` +
2605
+ `openai-api-base: ${LLM_PROXY_BASE}\n` +
2606
+ `# --- end shomra ---\n`;
2607
+ fs.writeFileSync(file, (text.endsWith('\n') || !text ? text : text + '\n') + block);
2608
+ return { file, changed: true };
2609
+ },
2610
+ };
2611
+
2612
+ // Normalize each agent's own hook payload into the {tool_name, tool_input,
2613
+ // tool_response, cwd, session_id} shape ToolGuardService/ToolResultGuardService
2614
+ // already understand (they already recognize Cursor/Cline/Aider-style tool
2615
+ // names like run_terminal_cmd/create_file — see tool-guard.service.ts).
2616
+ function normalizeGuardInput(agent, payload) {
2617
+ switch (agent) {
2618
+ case 'cursor': {
2619
+ if (typeof payload.command === 'string') {
2620
+ return { tool_name: 'Bash', tool_input: { command: payload.command }, cwd: payload.cwd || payload.workspace_roots?.[0], session_id: payload.conversation_id };
2621
+ }
2622
+ if (payload.tool_name || payload.tool) {
2623
+ const name = payload.tool_name || payload.tool;
2624
+ return { tool_name: String(name).startsWith('mcp') ? name : `mcp__${name}`, tool_input: payload.tool_input ?? payload.arguments, tool_response: payload.tool_response ?? payload.result, cwd: payload.cwd, session_id: payload.conversation_id };
2625
+ }
2626
+ if (typeof payload.file_path === 'string') {
2627
+ return { tool_name: 'Edit', tool_input: { file_path: payload.file_path, content: payload.content ?? payload.new_content }, cwd: payload.cwd, session_id: payload.conversation_id };
2628
+ }
2629
+ return { tool_name: payload.hook_event_name || 'unknown', tool_input: payload, session_id: payload.conversation_id };
2630
+ }
2631
+ case 'windsurf': {
2632
+ const info = payload.tool_info || {};
2633
+ if (typeof info.command_line === 'string') return { tool_name: 'Bash', tool_input: { command: info.command_line }, session_id: payload.trajectory_id };
2634
+ if (typeof info.file_path === 'string') return { tool_name: 'Edit', tool_input: { file_path: info.file_path, content: info.content }, tool_response: info.result, session_id: payload.trajectory_id };
2635
+ return { tool_name: payload.agent_action_name || 'unknown', tool_input: info, tool_response: info.result, session_id: payload.trajectory_id };
2636
+ }
2637
+ case 'copilot':
2638
+ return {
2639
+ tool_name: payload.toolName || payload.tool_name,
2640
+ tool_input: payload.toolArgs || payload.tool_input,
2641
+ tool_response: payload.toolResponse ?? payload.tool_response,
2642
+ cwd: payload.cwd,
2643
+ session_id: payload.sessionId || payload.session_id,
2644
+ };
2645
+ case 'cline': {
2646
+ // Cline names a tool in `tool`/`tool_name`/`name` and its args in
2647
+ // `tool_input`/`input`/`arguments`. MCP calls come through use_mcp_tool.
2648
+ const name = payload.tool_name || payload.tool || payload.name;
2649
+ const input = payload.tool_input ?? payload.input ?? payload.arguments ?? payload.params;
2650
+ if (name === 'use_mcp_tool') {
2651
+ const server = payload.server_name || input?.server_name || 'server';
2652
+ const mcpTool = input?.tool_name || input?.name || 'tool';
2653
+ return { tool_name: `mcp__${server}__${mcpTool}`, tool_input: input?.arguments ?? input, tool_response: payload.tool_response ?? payload.result, cwd: payload.cwd, session_id: payload.task_id || payload.session_id };
2654
+ }
2655
+ return { tool_name: name, tool_input: input, tool_response: payload.tool_response ?? payload.result, cwd: payload.cwd, session_id: payload.task_id || payload.session_id };
2656
+ }
2657
+ case 'gemini':
2658
+ case 'codex':
2659
+ case 'claude':
2660
+ case 'aider':
2661
+ default:
2662
+ return { tool_name: payload.tool_name, tool_input: payload.tool_input, tool_response: payload.tool_response, cwd: payload.cwd, session_id: payload.session_id };
2663
+ }
2664
+ }
2665
+
2666
+ // ── tiered-guard classification (Tier 0 local vs Tier 2 escalate) ──
2667
+ // Paths that ARE an AI artifact — a write here is install-time behaviour the
2668
+ // server's full gate must vet against org policy (mirror of PATH_KIND server-side).
2669
+ const ARTIFACT_PATH_RE = /(^|\/)(\.?mcp\.json|SKILL\.md|CLAUDE\.md|AGENTS\.md|GEMINI\.md|\.cursorrules|\.windsurfrules|\.aider\.conf\.yml|agent[-_]card\.json)$|(^|\/)\.claude\/(commands|agents)\/[^/]+\.md$|(^|\/)\.claude\/settings(\.local)?\.json$|(^|\/)\.well-known\/agent(-card)?\.json$|(^|\/)\.clinerules|(^|\/)\.github\/copilot-instructions\.md$/i;
2670
+ const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'create_file', 'str_replace_editor', 'str_replace_based_edit_tool', 'write_to_file', 'replace_in_file', 'new_rule']);
2671
+ const SHELL_TOOLS_RE = /^(bash|shell|sh|run_command|run_terminal_cmd|execute_command|terminal|exec)$/i;
2672
+ // A tool call that reaches the network (a flow-taint EXFIL SINK) — must reach
2673
+ // the server so session-scoped information-flow control can see it.
2674
+ const EGRESS_TOOL_RE = /fetch|web|http|browser|request|download|curl|url|open/i;
2675
+ const EGRESS_CMD_RE = /\b(curl|wget|nc|ncat|http|https|invoke-restmethod|invoke-webrequest|irm|iwr|scp|rsync|ftp|telnet)\b/i;
2676
+
2677
+ /** The scannable text of a tool call: shell command, written content, or args. */
2678
+ function guardText(tool, input) {
2679
+ const parts = [];
2680
+ if (typeof input.command === 'string') parts.push(input.command);
2681
+ if (typeof input.cmd === 'string') parts.push(input.cmd);
2682
+ if (typeof input.script === 'string') parts.push(input.script);
2683
+ if (typeof input.content === 'string') parts.push(input.content);
2684
+ if (typeof input.new_string === 'string') parts.push(input.new_string);
2685
+ if (typeof input.new_source === 'string') parts.push(input.new_source);
2686
+ if (Array.isArray(input.edits)) parts.push(input.edits.map((e) => e?.new_string ?? '').join('\n'));
2687
+ if (!parts.length) { try { parts.push(JSON.stringify(input)); } catch { parts.push(String(input)); } }
2688
+ return parts.join('\n');
2689
+ }
2690
+
2691
+ // ── false-positive control: path allowlist for the runtime hooks ──────────────
2692
+ // The static `shomra check` honors .shomraignore; the runtime firewall didn't.
2693
+ // A dev needs a friction-free way to mark files known-safe (the security tool's
2694
+ // own detection source, test fixtures, generated code) so a benign pattern in
2695
+ // source isn't withheld. Two layers, both keyed on the target file path: a repo
2696
+ // .shomraignore and a SHOMRA_GUARD_IGNORE env glob list. Cached per root.
2697
+ const _guardIgnoreCache = new Map();
2698
+ function guardIgnoreGlobs(root) {
2699
+ if (_guardIgnoreCache.has(root)) return _guardIgnoreCache.get(root);
2700
+ const globs = [];
2701
+ try { for (const re of loadIgnoreRules(root).fileGlobs) globs.push(re); } catch { /* no .shomraignore */ }
2702
+ const env = process.env.SHOMRA_GUARD_IGNORE;
2703
+ if (env) for (const g of String(env).split(/[,\n]+/).map((s) => s.trim()).filter(Boolean)) { try { globs.push(globToRe(g)); } catch { /* bad glob */ } }
2704
+ _guardIgnoreCache.set(root, globs);
2705
+ return globs;
2706
+ }
2707
+
2708
+ /** The file a tool call reads/writes, if any. */
2709
+ function guardTargetPath(norm) {
2710
+ const i = norm.tool_input || {};
2711
+ const p = i.file_path ?? i.path ?? i.notebook_path ?? i.filename ?? null;
2712
+ return typeof p === 'string' && p.trim() ? p : null;
2713
+ }
2714
+
2715
+ /** Is this file on the runtime allowlist (.shomraignore / SHOMRA_GUARD_IGNORE)? */
2716
+ function guardPathAllowlisted(cwd, filePath) {
2717
+ if (!filePath) return false;
2718
+ const root = cwd || process.cwd();
2719
+ let rel;
2720
+ try { rel = path.relative(root, path.resolve(root, filePath)); } catch { rel = filePath; }
2721
+ rel = String(rel).split(path.sep).join('/');
2722
+ const base = rel.split('/').pop();
2723
+ return guardIgnoreGlobs(root).some((re) => re.test(rel) || re.test(base));
2724
+ }
2725
+
2726
+ /**
2727
+ * Does this call need the server's authoritative check (Tier 2), or can a clean
2728
+ * local verdict stand on its own? We escalate only what the server adds value
2729
+ * over the local floor for: artifact installs (org policy), MCP calls/installs
2730
+ * (governance), agent-identity calls (authorization), and network egress (flow
2731
+ * taint). Everything else — a benign `ls`, a normal source-file edit — is
2732
+ * decided locally with zero network.
2733
+ */
2734
+ function guardNeedsServer(tool, input, hasIdentity) {
2735
+ if (hasIdentity) return true; // identity authz is server-side
2736
+ if (WRITE_TOOLS.has(tool)) {
2737
+ const target = String(input.file_path ?? input.path ?? input.notebook_path ?? '').replace(/\\/g, '/');
2738
+ return ARTIFACT_PATH_RE.test(target);
2739
+ }
2740
+ if (tool && tool.startsWith('mcp__')) return true;
2741
+ if (EGRESS_TOOL_RE.test(tool || '')) return true;
2742
+ if (SHELL_TOOLS_RE.test(tool || '')) {
2743
+ const cmd = String(input.command ?? input.cmd ?? input.script ?? '');
2744
+ if (EGRESS_CMD_RE.test(cmd)) return true; // egress sink
2745
+ if (/\bmcp\s+add\b|claude\s+mcp\b|@modelcontextprotocol\b|\bmcp[-_]server\b/i.test(cmd)) return true; // MCP install
2746
+ }
2747
+ const url = input?.url ?? input?.uri ?? input?.href ?? input?.endpoint;
2748
+ if (typeof url === 'string' && url) return true; // any tool carrying a URL = egress
2749
+ return false;
2750
+ }
2751
+
2752
+ // Deny signal each agent expects back on its PreToolUse-equivalent hook.
2753
+ // Windsurf has no JSON contract — only an exit code (2 = block, stderr = reason).
2754
+ function emitGuardDeny(agent, reason) {
2755
+ if (agent === 'windsurf') {
2756
+ process.stderr.write(reason);
2757
+ process.exit(2);
2758
+ }
2759
+ const bodies = {
2760
+ cursor: () => ({ permission: 'deny', user_message: reason, agent_message: reason }),
2761
+ copilot: () => ({ permissionDecision: 'deny', permissionDecisionReason: reason }),
2762
+ gemini: () => ({ decision: 'deny', reason }),
2763
+ codex: () => ({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } }),
2764
+ cline: () => ({ decision: 'deny', reason, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } }),
2765
+ claude: () => ({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } }),
2766
+ };
2767
+ process.stdout.write(JSON.stringify((bodies[agent] || bodies.claude)()));
2768
+ process.exit(0);
2769
+ }
2770
+
2771
+ // Block signal each agent expects back on its PostToolUse-equivalent hook.
2772
+ function emitResultBlock(agent, reason) {
2773
+ // Windsurf's post_* hooks are documented as visibility-only — the finding
2774
+ // still lands in Gate Activity via the API call above, but there is no
2775
+ // signal that withholds the result from the model.
2776
+ if (agent === 'windsurf') {
2777
+ console.error(dim(reason));
2778
+ process.exit(0);
2779
+ }
2780
+ const bodies = {
2781
+ cursor: () => ({ permission: 'deny', user_message: reason, agent_message: reason }),
2782
+ copilot: () => ({ permissionDecision: 'deny', permissionDecisionReason: reason }),
2783
+ gemini: () => ({ decision: 'deny', reason }),
2784
+ codex: () => ({ decision: 'block', reason, hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: reason } }),
2785
+ cline: () => ({ decision: 'block', reason, hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: reason } }),
2786
+ claude: () => ({ decision: 'block', reason, hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: reason } }),
2787
+ };
2788
+ process.stdout.write(JSON.stringify((bodies[agent] || bodies.claude)()));
2789
+ process.exit(0);
2790
+ }
2791
+
2792
+ // A non-blocking WARNING that surfaces to the user before the call proceeds
2793
+ // (agent "ask" where supported). Used for a known-vulnerable-but-not-critical
2794
+ // model load: don't hard-block, but don't let it pass silently either.
2795
+ function emitGuardAsk(agent, reason) {
2796
+ const bodies = {
2797
+ cursor: () => ({ permission: 'ask', user_message: reason, agent_message: reason }),
2798
+ copilot: () => ({ permissionDecision: 'ask', permissionDecisionReason: reason }),
2799
+ gemini: () => ({ decision: 'ask', reason }),
2800
+ codex: () => ({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask', permissionDecisionReason: reason } }),
2801
+ cline: () => ({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask', permissionDecisionReason: reason } }),
2802
+ claude: () => ({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask', permissionDecisionReason: reason } }),
2803
+ };
2804
+ // windsurf / unknown agents can't "ask" — print a visible note and allow.
2805
+ if (agent === 'windsurf' || !bodies[agent]) { process.stderr.write(reason + '\n'); process.exit(0); }
2806
+ process.stdout.write(JSON.stringify(bodies[agent]()));
2807
+ process.exit(0);
2808
+ }
2809
+
2810
+ const MODEL_WRITE_TOOLS = ['write', 'edit', 'multiedit', 'notebookedit', 'create_file', 'str_replace_editor', 'apply_patch', 'write_file'];
2811
+
2812
+ /**
2813
+ * Screen a file-writing tool call for a KNOWN-VULNERABLE AI model being added to
2814
+ * the code (e.g. `from_pretrained("openai-community/gpt2")`). Runs on the content
2815
+ * about to be written, so the developer is warned BEFORE the vulnerable load
2816
+ * lands — without relying on the LLM to think to check. Local detection first
2817
+ * (zero network); only a real model reference triggers the index lookup. Emits an
2818
+ * "ask" (exits) when a flagged model is found; otherwise returns to let the normal
2819
+ * flow continue. Disable with SHOMRA_MODEL_GUARD=0.
2820
+ */
2821
+ async function screenModelLoad(agent, tool, input, url) {
2822
+ if (process.env.SHOMRA_MODEL_GUARD === '0' || String(process.env.SHOMRA_MODEL_GUARD).toLowerCase() === 'false') return;
2823
+ if (!MODEL_WRITE_TOOLS.includes(String(tool).toLowerCase())) return;
2824
+ const filePath = input.file_path || input.path || input.filePath;
2825
+ if (!filePath || !isModelRefScannable(filePath)) return;
2826
+ let content = '';
2827
+ if (typeof input.content === 'string') content = input.content;
2828
+ else if (typeof input.new_string === 'string') content = input.new_string;
2829
+ else if (typeof input.new_str === 'string') content = input.new_str;
2830
+ else if (Array.isArray(input.edits)) content = input.edits.map((e) => e.new_string || e.new_str || '').join('\n');
2831
+ if (!content) return;
2832
+ const refs = scanModelRefs(content, path.basename(String(filePath))).filter((r) => r.source === 'hf');
2833
+ if (!refs.length) return; // modelLookup is cache-first + breaker-aware, so don't bail here
2834
+
2835
+ const flagged = [];
2836
+ for (const r of refs) {
2837
+ let lk;
2838
+ try { lk = await modelLookup(url, r.id, r.revision); } catch { return; } // uncached + backend down → can't judge, stay silent
2839
+ const findings = (lk && lk.findings) || [];
2840
+ const worst = findings.reduce((m, f) => Math.max(m, MODEL_SEV_RANK[f.severity] || 0), 0);
2841
+ const bad = lk && lk.found && (lk.verdict === 'FAIL' || lk.verdict === 'REVIEW' || worst >= MODEL_SEV_RANK.HIGH);
2842
+ if (bad) flagged.push({ id: lk.resolvedId || r.id, verdict: lk.verdict, riskScore: lk.riskScore, findings, fix: modelFixPlan(findings, lk.sha) });
2843
+ }
2844
+ if (!flagged.length) return;
2845
+
2846
+ const m = flagged[0];
2847
+ const titles = m.findings.slice(0, 2).map((f) => f.title).join('; ');
2848
+ const kw = ((m.fix || {}).kwargs || []).map((k) => `${k.name}=${k.value}`).join(', ');
2849
+ const extra = flagged.length > 1 ? ` (+${flagged.length - 1} more flagged model${flagged.length - 1 === 1 ? '' : 's'})` : '';
2850
+ const reason =
2851
+ `⚠ Shomra: "${m.id}" has known vulnerabilities (${m.verdict}, risk ${m.riskScore}) — ${titles}.${extra} ` +
2852
+ `Safer: add ${kw || 'safe-loading arguments'} to the load call, pin the reviewed revision, or choose another model. (SHOMRA_MODEL_GUARD=0 to silence.)`;
2853
+ await reportGuardDecision(url, resolveSettings(loadConfig()).apiKey, null, { tool_name: tool, tool_input: { file_path: filePath }, client_decision: 'FLAG', client_reason: `vulnerable model: ${m.id}`, machine: gateMachine(), env: detectEnv(), agent });
2854
+ emitGuardAsk(agent, reason); // exits
2855
+ }
2856
+
2857
+ function resolveAgentFlag(flags) {
2858
+ const agent = String(flags.agent || 'claude').toLowerCase();
2859
+ return AGENT_KEYS.includes(agent) ? agent : 'claude';
2860
+ }
2861
+
2862
+ function envFlag(name) {
2863
+ return ['1', 'true', 'yes', 'on'].includes(String(process.env[name] ?? '').toLowerCase());
2864
+ }
2865
+
2866
+ /** The gate/tool-call request body, optionally carrying the Tier-0 verdict. */
2867
+ function buildGuardBody(norm, agent, clientDecision, clientReason) {
2868
+ return {
2869
+ tool_name: norm.tool_name,
2870
+ tool_input: norm.tool_input,
2871
+ cwd: norm.cwd,
2872
+ session_id: norm.session_id,
2873
+ machine: gateMachine(),
2874
+ env: detectEnv(),
2875
+ agent,
2876
+ ...(clientDecision ? { client_decision: clientDecision, client_reason: clientReason } : {}),
2877
+ };
2878
+ }
2879
+
2880
+ /**
2881
+ * Best-effort record of a decision the local Tier-0 guard already made, so a
2882
+ * locally-blocked call still lands in Gate Activity when the backend is up.
2883
+ * Breaker-gated + short timeout so a down backend never delays the block.
2884
+ */
2885
+ async function reportGuardDecision(url, apiKey, agentId, body) {
2886
+ if (!apiKey || breakerOpen()) return;
2887
+ try {
2888
+ const ctrl = new AbortController();
2889
+ const timer = setTimeout(() => ctrl.abort(), Math.min(guardTimeoutMs(), 1000));
2890
+ await fetch(`${url}/gate/tool-call`, {
2891
+ method: 'POST',
2892
+ headers: { 'Content-Type': 'application/json', 'X-Shomra-Key': apiKey, ...(agentId ? { 'X-Shomra-Agent': agentId } : {}), Connection: 'close' },
2893
+ body: JSON.stringify(body),
2894
+ signal: ctrl.signal,
2895
+ });
2896
+ clearTimeout(timer);
2897
+ breakerReset();
2898
+ } catch {
2899
+ breakerTrip();
2900
+ }
2901
+ }
2902
+
2903
+ /**
2904
+ * Tiered pre-tool-call guard.
2905
+ * Tier 0 (local, no network): high-confidence detectors decide the dangerous
2906
+ * majority on-box — a CRITICAL signal BLOCKs instantly even with no backend,
2907
+ * no API key, or a blocked network. This is the un-DoS-able floor.
2908
+ * Tier 2 (server): only policy-relevant calls (artifact installs, MCP calls,
2909
+ * agent-identity, network egress, or anything Tier 0 FLAGged) escalate for
2910
+ * the full org-policy / identity / governance / flow engine.
2911
+ * Skip: benign, locally-cleared, non-policy-relevant calls allow with ZERO
2912
+ * network — that's the bulk of calls and the whole overhead problem.
2913
+ */
2914
+ async function cmdToolGuard(flags) {
2915
+ const agent = resolveAgentFlag(flags);
2916
+ const agentId = resolveAgentIdentityHandle(flags);
2917
+ const strict = envFlag('SHOMRA_GUARD_STRICT');
2918
+ const localOff = process.env.SHOMRA_GUARD_LOCAL === '0' || String(process.env.SHOMRA_GUARD_LOCAL).toLowerCase() === 'false';
2919
+ const alwaysEscalate = envFlag('SHOMRA_GUARD_ALWAYS_ESCALATE');
2920
+ const cfg = loadConfig();
2921
+ const { apiKey, url } = resolveSettings(cfg);
2922
+
2923
+ let payload = {};
2924
+ try {
2925
+ payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
2926
+ } catch {
2927
+ process.exit(0); // unparseable input — don't block the session
2928
+ }
2929
+
2930
+ const norm = normalizeGuardInput(agent, payload);
2931
+ const tool = (norm.tool_name ?? '').trim();
2932
+ const input = norm.tool_input ?? {};
2933
+
2934
+ // ── Tier 0: local, in-process, zero-network ──
2935
+ let local = { verdict: 'ALLOW', top: null, findings: [] };
2936
+ if (!localOff) {
2937
+ const scan = localScan(guardText(tool, input));
2938
+ // File WRITES screen content: a pattern living in a string / regex / comment
2939
+ // is a rule or a sample, not a live command — down-rank so editing detection
2940
+ // source or fixtures isn't blocked. Shell commands stay strict (a quoted
2941
+ // payload still runs). An explicitly allowlisted path is skipped entirely.
2942
+ const isWrite = WRITE_TOOLS.has(tool);
2943
+ const allow = isWrite && guardPathAllowlisted(norm.cwd, guardTargetPath(norm));
2944
+ const findings = allow ? [] : isWrite ? downrankCodeContext(scan.findings) : scan.findings;
2945
+ local = { ...grade(findings), top: findings.find((f) => f.severity === 'CRITICAL') || findings[0] || null, findings };
2946
+ if (local.verdict === 'BLOCK') {
2947
+ const reason = `Blocked on-machine by Shomra: ${local.top?.label || 'dangerous tool call'}.`;
2948
+ await reportGuardDecision(url, apiKey, agentId, buildGuardBody(norm, agent, 'BLOCK', local.top?.label));
2949
+ emitGuardDeny(agent, reason); // exits
2950
+ }
2951
+ }
2952
+
2953
+ // Model-load safety: if this write ADDS a known-vulnerable AI model, warn (ask)
2954
+ // before it lands — deterministic, not dependent on the LLM choosing to check.
2955
+ // Uses the public model index, so it works even before enrollment.
2956
+ await screenModelLoad(agent, tool, input, url);
2957
+
2958
+ // No key → nothing to escalate to; the local floor already ran (unbreakable).
2959
+ if (!apiKey) {
2960
+ if (strict) emitGuardDeny(agent, 'Shomra is not configured on this machine (SHOMRA_GUARD_STRICT). Run: shomra init --key shm_…');
2961
+ process.exit(0);
2962
+ }
2963
+
2964
+ // Memory integrity: capture a persistent-memory write (AGENT provenance) for
2965
+ // the integrity timeline / drift / poison analysis. Best-effort, breaker-gated.
2966
+ const memPath = input.file_path || input.path;
2967
+ if (memPath && isMemoryPath(memPath) && !breakerOpen()) {
2968
+ const memContent =
2969
+ typeof input.content === 'string' ? input.content
2970
+ : typeof input.new_string === 'string' ? input.new_string : null;
2971
+ if (memContent != null) {
2972
+ await reportMemoryWrite(url, apiKey, {
2973
+ path: String(memPath).split(path.sep).join('/'),
2974
+ name: path.basename(String(memPath)),
2975
+ content: memContent,
2976
+ writer: 'AGENT',
2977
+ source: os.hostname(),
2978
+ actor: os.userInfo().username,
2979
+ sessionId: norm.session_id,
2980
+ });
2981
+ }
2982
+ }
2983
+
2984
+ // ── Decide escalation to Tier 2 ──
2985
+ const escalate = alwaysEscalate || local.verdict === 'FLAG' || guardNeedsServer(tool, input, !!agentId);
2986
+ if (!escalate) process.exit(0); // benign + locally-cleared + not policy-relevant → allow, no network
2987
+
2988
+ // Breaker: skip the round-trip while the backend is known-down (fail-open —
2989
+ // Tier 0 already caught the dangerous cases). Strict opts out to stay closed.
2990
+ if (!strict && breakerOpen()) process.exit(0);
2991
+
2992
+ const body = buildGuardBody(
2993
+ norm,
2994
+ agent,
2995
+ local.verdict === 'FLAG' ? 'FLAG' : undefined,
2996
+ local.verdict === 'FLAG' ? local.top?.label : undefined,
2997
+ );
2998
+ let res;
2999
+ try {
3000
+ const ctrl = new AbortController();
3001
+ const timer = setTimeout(() => ctrl.abort(), guardTimeoutMs());
3002
+ const r = await fetch(`${url}/gate/tool-call`, {
3003
+ method: 'POST',
3004
+ headers: { 'Content-Type': 'application/json', 'X-Shomra-Key': apiKey, ...(agentId ? { 'X-Shomra-Agent': agentId } : {}), Connection: 'close' },
3005
+ body: JSON.stringify(body),
3006
+ signal: ctrl.signal,
3007
+ });
3008
+ clearTimeout(timer);
3009
+ res = await r.json();
3010
+ breakerReset(); // healthy response — clear any tripped breaker
3011
+ } catch (e) {
3012
+ breakerTrip(); // remember this failure so the next calls skip the wait
3013
+ if (strict) emitGuardDeny(agent, `Shomra guard could not be reached (${e.message}); blocked by fail-closed policy.`);
3014
+ process.exit(0); // fail-open (Tier 0 already screened the dangerous patterns)
3015
+ }
3016
+
3017
+ if (res && res.decision === 'BLOCK') {
3018
+ emitGuardDeny(agent, res.reason || 'Blocked by Shomra security policy.');
3019
+ }
3020
+ // ALLOW / FLAG → stay silent and let the agent's normal permission flow run.
3021
+ process.exit(0);
3022
+ }
3023
+
3024
+ async function cmdResultGuard(flags) {
3025
+ const agent = resolveAgentFlag(flags);
3026
+ const strict = process.env.SHOMRA_GUARD_STRICT === '1' || process.env.SHOMRA_GUARD_STRICT === 'true';
3027
+ const cfg = loadConfig();
3028
+ const { apiKey, url } = resolveSettings(cfg);
3029
+
3030
+ let payload = {};
3031
+ try {
3032
+ payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
3033
+ } catch {
3034
+ process.exit(0); // unparseable input — don't disrupt the session
3035
+ }
3036
+
3037
+ const norm = normalizeGuardInput(agent, payload);
3038
+ const response = norm.tool_response ?? payload.tool_response;
3039
+ const respText = typeof response === 'string' ? response : (() => { try { return JSON.stringify(response); } catch { return String(response ?? ''); } })();
3040
+
3041
+ // ── Tier 0: local screen of the RETURNED content (indirect-injection channel)
3042
+ // — a CRITICAL injection / RCE / exfil payload in a fetched page or file read
3043
+ // is withheld on-box, even offline. The server does the nuanced flow-taint pass.
3044
+ const localOff = process.env.SHOMRA_GUARD_LOCAL === '0' || String(process.env.SHOMRA_GUARD_LOCAL).toLowerCase() === 'false';
3045
+ // Returned content is data the agent READS: a pattern inside a literal /
3046
+ // comment / fenced example is not a live instruction, so down-rank it — reading
3047
+ // detection source or docs that describe an attack must not be withheld
3048
+ // (executing it is separately gated by the pre-call firewall). An allowlisted
3049
+ // path skips screening entirely.
3050
+ const allow = guardPathAllowlisted(norm.cwd, guardTargetPath(norm));
3051
+ const scan = localScan(respText);
3052
+ const findings = allow ? [] : downrankCodeContext(scan.findings);
3053
+ const codeAware = grade(findings);
3054
+ // The block-worthy signals — a CRITICAL payload/secret, or a prompt injection —
3055
+ // are what withhold content. When every one of those sits in a code/data
3056
+ // context (a rule definition, a quoted sample, a fenced/commented example) the
3057
+ // returned content is source or docs, not a live directive: suppress the
3058
+ // withhold, including the server's regex-only block (still recorded server-side
3059
+ // for visibility). Detector over-matches on benign code (a `.exec()` call, a
3060
+ // `||`) are HIGH-shell noise, not injection/critical, so they don't force a
3061
+ // block. A real non-code injection or CRITICAL keeps it.
3062
+ const nonCodeCritical = scan.findings.some((f) => f.severity === 'CRITICAL' && !f.codeContext);
3063
+ const nonCodeInjection = scan.findings.some((f) => f.category === 'injection' && !f.codeContext);
3064
+ const suppressBlock = allow || (scan.findings.length > 0 && !nonCodeCritical && !nonCodeInjection);
3065
+ if (!localOff && !suppressBlock && codeAware.verdict === 'BLOCK') {
3066
+ const top = findings.find((f) => f.severity === 'CRITICAL') || findings[0];
3067
+ emitResultBlock(agent, `Shomra withheld this tool result (on-machine): ${top?.label || 'malicious content'}. Do not act on it.`);
3068
+ }
3069
+
3070
+ if (!apiKey) {
3071
+ if (strict) emitResultBlock(agent, 'Shomra is not configured on this machine (SHOMRA_GUARD_STRICT). Run: shomra init --key shm_…');
3072
+ process.exit(0);
3073
+ }
3074
+
3075
+ // Circuit breaker: skip the round-trip while the backend is known-down
3076
+ // (fail-open). Strict mode opts out to stay fail-closed.
3077
+ if (!strict && breakerOpen()) process.exit(0);
3078
+
3079
+ const body = {
3080
+ tool_name: norm.tool_name,
3081
+ tool_input: norm.tool_input,
3082
+ tool_response: response,
3083
+ cwd: norm.cwd,
3084
+ session_id: norm.session_id,
3085
+ machine: gateMachine(),
3086
+ env: detectEnv(),
3087
+ agent,
3088
+ };
3089
+
3090
+ let res;
3091
+ try {
3092
+ const ctrl = new AbortController();
3093
+ const timer = setTimeout(() => ctrl.abort(), guardTimeoutMs());
3094
+ const r = await fetch(`${url}/gate/tool-result`, {
3095
+ method: 'POST',
3096
+ headers: { 'Content-Type': 'application/json', 'X-Shomra-Key': apiKey, Connection: 'close' },
3097
+ body: JSON.stringify(body),
3098
+ signal: ctrl.signal,
3099
+ });
3100
+ clearTimeout(timer);
3101
+ res = await r.json();
3102
+ breakerReset();
3103
+ } catch (e) {
3104
+ breakerTrip();
3105
+ if (strict) emitResultBlock(agent, `Shomra result-guard could not be reached (${e.message}); blocked by fail-closed policy.`);
3106
+ process.exit(0); // fail-open
3107
+ }
3108
+
3109
+ if (res && res.decision === 'BLOCK' && !suppressBlock) {
3110
+ emitResultBlock(agent, res.reason || 'Shomra withheld this tool result: it carries prompt injection or exfil content. Do not act on it.');
3111
+ }
3112
+ // ALLOW / FLAG (or a context-suppressed block) → stay silent; the result flows
3113
+ // to the agent as normal.
3114
+ process.exit(0);
3115
+ }
3116
+
3117
+ // Wire the runtime firewall into one or more coding agents' hook systems.
3118
+ // Default (no --agent) targets Claude Code only, unchanged from before
3119
+ // multi-agent support existed. `--agent cursor,windsurf` or `--agent all`
3120
+ // installs into others too.
3121
+ function cmdInstallHook(flags) {
3122
+ const global = !!flags.global;
3123
+ const requested = flags.agent
3124
+ ? String(flags.agent).toLowerCase().split(',').map((s) => s.trim()).filter(Boolean)
3125
+ : ['claude'];
3126
+ const unknown = requested.filter((a) => a !== 'all' && !AGENT_KEYS.includes(a));
3127
+ if (unknown.length) {
3128
+ console.error(red('✗') + ` Unknown agent(s): ${unknown.join(', ')}. Supported: ${AGENT_KEYS.join(', ')}, all.`);
3129
+ process.exit(1);
3130
+ }
3131
+ const targets = requested.includes('all') ? AGENT_KEYS : requested;
3132
+
3133
+ for (const agent of targets) {
3134
+ const { file, changed } = AGENT_INSTALLERS[agent](global);
3135
+ if (changed) {
3136
+ console.log(green('✓') + ` Installed the Shomra runtime firewall for ${bold(AGENT_LABELS[agent])} → ${bold(file)}`);
3137
+ } else {
3138
+ console.log(yellow('•') + ` Shomra runtime firewall already installed for ${AGENT_LABELS[agent]} in ${bold(file)}`);
3139
+ }
3140
+ if (agent === 'windsurf') {
3141
+ console.log(dim(' Note: Windsurf\'s post-hooks can flag/log but not withhold a tool result.'));
3142
+ }
3143
+ if (agent === 'aider') {
3144
+ console.log(dim(' Note: Aider has no tool hook — this routes its model calls through the'));
3145
+ console.log(dim(' Shomra LLM Guard proxy. Start it with ') + 'shomra llm-proxy' + dim(' and set your API key.'));
3146
+ }
3147
+ }
3148
+ console.log(dim('\n PreToolUse: screens every shell command, artifact write, and MCP call BEFORE it runs —'));
3149
+ console.log(dim(' and vets AI model loads the agent writes (from_pretrained / hf_hub /'));
3150
+ console.log(dim(' torch.hub …) against the Shomra Model Index, so a known-vulnerable model'));
3151
+ console.log(dim(' is flagged with its fix BEFORE the load lands. (SHOMRA_MODEL_GUARD=0 to silence.)'));
3152
+ console.log(dim(' PostToolUse: screens content fetched pages / file reads / MCP responses bring BACK'));
3153
+ console.log(dim(' into the agent context — prompt injection, exfil sinks, hidden payloads.'));
3154
+ console.log(dim(' Blocked calls/results are refused with a reason; every decision lands in Shomra → Gate Activity.'));
3155
+ console.log(dim(' Dangerous calls (curl|sh, reverse shells, secrets, injection) are blocked ON-MACHINE with'));
3156
+ console.log(dim(' no network; only policy-relevant calls escalate to the backend, so a slow/down backend'));
3157
+ console.log(dim(' never freezes the agent. Tip: ') + 'SHOMRA_GUARD_STRICT=1' + dim(' also fails-closed on the server tier.'));
3158
+ }
3159
+
3160
+ // ── shomra doctor: "am I safe?" — one-command posture of this machine ────────
3161
+ //
3162
+ // shomra doctor [--json]
3163
+ //
3164
+ // Discovers the AI tooling on this box (coding agents, MCP servers, rules files,
3165
+ // model keys, AI tools), locally scans the scannable ones, and prints a posture
3166
+ // score + the top fixes. Zero backend needed — the fastest "show a colleague"
3167
+ // first-run. Pairs with `shomra protect` (unguarded agents) and `shomra check`.
3168
+ function cmdDoctor(flags) {
3169
+ const assets = discoverAll();
3170
+ const by = (t) => assets.filter((a) => a.type === t);
3171
+ const agents = by('AI_AGENT'), mcps = by('MCP_SERVER'), rules = by('AI_RULES');
3172
+ const keys = by('MODEL_KEY'), tools = by('AI_TOOL');
3173
+
3174
+ // Local risk scan of whatever content discovery captured (no backend).
3175
+ const risky = [];
3176
+ const scanAsset = (a, kind) => {
3177
+ const content = a.content || a.metadata?.content;
3178
+ if (!content) return;
3179
+ const g = localGate(content, { kind, path: a.metadata?.configFile || a.metadata?.file || a.name });
3180
+ if (g.verdict !== 'ALLOW') risky.push({ name: a.name, kind, decision: g.verdict, riskScore: g.riskScore, top: (g.findings[0] || {}).title });
3181
+ };
3182
+ for (const m of mcps) scanAsset(m, 'mcp');
3183
+ for (const r of rules) scanAsset(r, 'rules');
3184
+
3185
+ const unguarded = agents.filter((a) => !a.metadata?.guarded);
3186
+ const dotenvKeys = keys.filter((k) => k.metadata?.source === 'dotenv');
3187
+ const blockCount = risky.filter((r) => r.decision === 'BLOCK').length;
3188
+
3189
+ let score = 100;
3190
+ score -= Math.min(40, unguarded.length * 8);
3191
+ for (const r of risky) score -= r.decision === 'BLOCK' ? 15 : 5;
3192
+ score -= Math.min(30, dotenvKeys.length * 10);
3193
+ score = Math.max(0, Math.round(score));
3194
+ const g = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 60 ? 'C' : score >= 40 ? 'D' : 'F';
3195
+ const scoreColor = score >= 75 ? green : score >= 50 ? yellow : red;
3196
+
3197
+ if (flags.json) {
3198
+ console.log(JSON.stringify({
3199
+ score, grade: g, hostname: os.hostname(),
3200
+ codingAgents: agents.length, unguarded: unguarded.length,
3201
+ mcpServers: mcps.length, rulesFiles: rules.length, aiTools: tools.length,
3202
+ modelKeys: keys.length, modelKeysInDotenv: dotenvKeys.length,
3203
+ riskyArtifacts: risky.length, risky,
3204
+ }, null, 2));
3205
+ return;
3206
+ }
3207
+
3208
+ console.log(bold(cyan('\n Shomra doctor')) + dim(` — ${os.hostname()}`));
3209
+ console.log(`\n ${bold('Posture')} ${scoreColor(bold(score + '/100'))} ${dim('· grade')} ${scoreColor(bold(g))}\n`);
3210
+ const row = (label, n, note) => console.log(` ${dim(String(label).padEnd(16))} ${bold(String(n).padStart(3))}${note ? ' ' + note : ''}`);
3211
+ row('Coding agents', agents.length, unguarded.length ? red(`${unguarded.length} UNGUARDED`) + dim(` · ${agents.length - unguarded.length} protected`) : green('all protected'));
3212
+ row('MCP servers', mcps.length, risky.filter((r) => r.kind === 'mcp').length ? yellow(`${risky.filter((r) => r.kind === 'mcp').length} risky`) : '');
3213
+ row('Rules files', rules.length, risky.filter((r) => r.kind === 'rules').length ? yellow(`${risky.filter((r) => r.kind === 'rules').length} risky`) : '');
3214
+ row('Model keys', keys.length, dotenvKeys.length ? yellow(`${dotenvKeys.length} in .env files`) : '');
3215
+ row('AI tools', tools.length, '');
3216
+
3217
+ if (risky.length) {
3218
+ console.log(dim('\n Risky artifacts:'));
3219
+ for (const r of risky.slice(0, 6)) {
3220
+ const dc = r.decision === 'BLOCK' ? red : yellow;
3221
+ console.log(` ${dc('●')} ${bold(r.name)} ${dim('(' + r.kind + ')')} ${dc(r.decision)} ${dim(r.top || '')}`);
3222
+ }
3223
+ }
3224
+
3225
+ const fixes = [];
3226
+ if (unguarded.length) fixes.push(`${red('!')} ${unguarded.length} coding agent${unguarded.length > 1 ? 's have' : ' has'} no runtime firewall → ${bold('shomra protect')}`);
3227
+ if (risky.length) fixes.push(`${yellow('!')} ${risky.length} risky MCP/rules artifact${risky.length > 1 ? 's' : ''} → ${bold('shomra check')} ${dim('or')} ${bold('shomra gate <file>')}`);
3228
+ if (dotenvKeys.length) fixes.push(`${yellow('!')} ${dotenvKeys.length} model key${dotenvKeys.length > 1 ? 's' : ''} in .env file${dotenvKeys.length > 1 ? 's' : ''} → rotate + ensure .gitignore covers them`);
3229
+ if (fixes.length) {
3230
+ console.log(bold('\n Top fixes:'));
3231
+ for (const f of fixes) console.log(` ${f}`);
3232
+ } else {
3233
+ console.log(green('\n ✓ No urgent fixes — nice posture.'));
3234
+ }
3235
+ console.log(dim(`\n ${loadConfig().apiKey ? 'Enrolled — run ' + bold('shomra report') + dim(' to sync this to your Shomra org.') : 'Run ' + bold('shomra init') + dim(' to apply org policy and sync posture.')}`) + '\n');
3236
+ }
3237
+
3238
+ // ── shomra protect: one command, wire the firewall for EVERY coding agent ────
3239
+ //
3240
+ // shomra protect [--local] [--force]
3241
+ //
3242
+ // `install-hook` protects one named agent; this discovers every supported coding
3243
+ // agent on the machine and wires the Pre/Post firewall for each unguarded one —
3244
+ // the zero-friction "seatbelt on everything" button. Global (machine-wide) by
3245
+ // default; --local scopes to this repo's .<agent> dirs.
3246
+ function cmdProtect(flags) {
3247
+ const assets = discoverAll();
3248
+ const labelToKey = Object.fromEntries(Object.entries(AGENT_LABELS).map(([k, v]) => [v, k]));
3249
+ const detected = assets
3250
+ .filter((a) => a.type === 'AI_AGENT')
3251
+ .map((a) => ({ label: a.name, key: labelToKey[a.name], guarded: !!a.metadata?.guarded }))
3252
+ .filter((a) => a.key && AGENT_INSTALLERS[a.key]);
3253
+
3254
+ if (!detected.length) {
3255
+ console.log(dim('\n No supported coding agents detected on this machine.'));
3256
+ console.log(dim(' Install one (Claude Code, Cursor, Gemini/Codex/Copilot CLI, Cline, Aider…) and re-run, or force all: ') + bold('shomra install-hook --agent all') + '\n');
3257
+ return;
3258
+ }
3259
+
3260
+ const global = !flags.local;
3261
+ console.log(bold(cyan('\n Shomra protect')) + dim(` — wiring the runtime firewall for ${detected.length} coding agent${detected.length > 1 ? 's' : ''} (${global ? 'machine-wide' : 'this repo'})`));
3262
+ let wired = 0, already = 0;
3263
+ for (const a of detected) {
3264
+ if (a.guarded && !flags.force) { already++; console.log(` ${yellow('•')} ${AGENT_LABELS[a.key]} ${dim('already protected')}`); continue; }
3265
+ try {
3266
+ const { file, changed } = AGENT_INSTALLERS[a.key](global);
3267
+ if (changed) { wired++; console.log(` ${green('✓')} Protected ${bold(AGENT_LABELS[a.key])} ${dim('→ ' + file)}`); }
3268
+ else { already++; console.log(` ${yellow('•')} ${AGENT_LABELS[a.key]} ${dim('already protected (' + file + ')')}`); }
3269
+ if (a.key === 'aider') console.log(dim(' Aider has no tool hook — routes model calls through the LLM Guard proxy. Start ') + bold('shomra llm-proxy') + dim('.'));
3270
+ } catch (e) {
3271
+ console.log(` ${red('✗')} ${AGENT_LABELS[a.key]} ${dim('— ' + e.message)}`);
3272
+ }
3273
+ }
3274
+ console.log(`\n ${wired ? green(`✓ ${wired} newly protected`) : green('✓ Already protected')}${already ? dim(` · ${already} already wired`) : ''}${dim(' — Pre/Post tool calls now screened on-machine.')}\n`);
3275
+ }
3276
+
3277
+ // ── shomra new: scaffold a secure-by-default AI artifact ─────────────────────
3278
+ //
3279
+ // shomra new skill|command|subagent|agent-card|mcp|rules [name]
3280
+ //
3281
+ // Generates the artifact from a least-privilege template (explicit narrow tool
3282
+ // grants, env-referenced secrets, https + auth on cards) and gates it to prove
3283
+ // it starts clean — "the right thing is the default thing."
3284
+ const NEW_TEMPLATES = {
3285
+ skill: (name) => ({
3286
+ file: path.join(name, 'SKILL.md'),
3287
+ content: `---\nname: ${name}\ndescription: One line — what this skill does and when to use it.\nallowed-tools: [Read]\n---\n\n# ${name}\n\nDescribe the skill's job here. Keep the tool grant least-privilege — add only the\ntools it truly needs (Read, Grep, …), never a wildcard ("*").\n\n## Steps\n1. …\n`,
3288
+ }),
3289
+ command: (name) => ({
3290
+ file: path.join('.claude', 'commands', `${name}.md`),
3291
+ content: `---\ndescription: One line — what this command does.\nallowed-tools: [Read, Grep]\n---\n\nWrite the prompt here. Avoid \`!\`-bash blocks that run before the prompt and\n\`@\`-references to secret files (.env, .ssh, *.pem) — both pull untrusted content\nstraight into the model.\n`,
3292
+ }),
3293
+ subagent: (name) => ({
3294
+ file: path.join('.claude', 'agents', `${name}.md`),
3295
+ content: `---\nname: ${name}\ndescription: When this subagent should be used.\ntools: [Read, Grep]\n---\n\nSystem prompt for the ${name} subagent. Grant only the tools it needs.\n`,
3296
+ }),
3297
+ 'agent-card': (name) => ({
3298
+ file: path.join('.well-known', 'agent-card.json'),
3299
+ content: JSON.stringify({
3300
+ name, description: 'One line — what this agent does.',
3301
+ url: `https://example.com/agents/${name}`, version: '0.1.0',
3302
+ securitySchemes: { bearer: { type: 'http', scheme: 'bearer' } },
3303
+ skills: [{ id: 'example', name: 'Example', description: 'What this skill does.' }],
3304
+ }, null, 2) + '\n',
3305
+ }),
3306
+ mcp: (name) => ({
3307
+ file: '.mcp.json',
3308
+ content: JSON.stringify({
3309
+ mcpServers: { [name]: { command: 'npx', args: ['-y', '@your-scope/your-mcp-server'], env: { API_TOKEN: '${env:API_TOKEN}' } } },
3310
+ }, null, 2) + '\n',
3311
+ }),
3312
+ rules: () => ({
3313
+ file: 'CLAUDE.md',
3314
+ content: `# Project rules\n\nGuidance the agent should follow in this repo. Legitimate standing directives are\nfine here — but never instruct the agent to ignore the system prompt, hide actions\nfrom the user, disable safety checks, or send data to an external host.\n\n## Conventions\n- …\n`,
3315
+ }),
3316
+ };
3317
+
3318
+ function cmdNew(flags, positional) {
3319
+ const kind = String(positional[0] || '').toLowerCase();
3320
+ const tmpl = NEW_TEMPLATES[kind];
3321
+ if (!tmpl) {
3322
+ console.error(red('✗') + ` Usage: ${bold('shomra new ' + Object.keys(NEW_TEMPLATES).join('|') + ' [name]')}`);
3323
+ process.exit(1);
3324
+ }
3325
+ const name = (positional[1] || (kind === 'rules' ? 'rules' : `my-${kind}`)).replace(/[^a-zA-Z0-9._-]/g, '-');
3326
+ const { file, content } = tmpl(name);
3327
+ const target = path.resolve(file);
3328
+ if (fs.existsSync(target) && !flags.force) {
3329
+ console.error(red('✗') + ` ${file} already exists. Use ${bold('--force')} to overwrite.`);
3330
+ process.exit(1);
3331
+ }
3332
+ fs.mkdirSync(path.dirname(target), { recursive: true });
3333
+ fs.writeFileSync(target, content);
3334
+ // Prove it starts clean.
3335
+ const g = localGate(content, { kind: kind === 'agent-card' ? 'agent-card' : kind === 'mcp' ? 'mcp' : kind === 'rules' ? 'rules' : kind, path: file });
3336
+ if (flags.json) { console.log(JSON.stringify({ created: file, kind, verdict: g.verdict }, null, 2)); return; }
3337
+ console.log(`\n ${green('✓ Created')} ${bold(file)} ${dim(`(${kind})`)}`);
3338
+ console.log(` ${g.verdict === 'ALLOW' ? green('✓ gate: clean') : yellow('gate: ' + g.verdict)} ${dim('— secure-by-default template. Edit, then')} ${bold('shomra gate ' + file)}${dim('.')}\n`);
3339
+ }
3340
+
3341
+ // ── shomra mcp add: vet an MCP server BEFORE it lands in a config ─────────────
3342
+ //
3343
+ // shomra mcp add <name> <command…> [--env K=V,K2=V2] [--config <file>] [--force]
3344
+ // shomra mcp add <name> --url <url> [--config <file>] [--force]
3345
+ // shomra mcp list [--config <file>]
3346
+ //
3347
+ // Never add an MCP server unvetted: builds the candidate config, gates it locally
3348
+ // (typosquat / plaintext / static-secret / dangerous launch), and only writes it
3349
+ // into the target config (default ./.mcp.json) when it passes. A BLOCK refuses
3350
+ // unless --force; a FLAG warns and proceeds.
3351
+ function parseEnvKV(str) {
3352
+ const env = {};
3353
+ for (const pair of String(str || '').split(',')) {
3354
+ const i = pair.indexOf('=');
3355
+ if (i > 0) env[pair.slice(0, i).trim()] = pair.slice(i + 1).trim();
3356
+ }
3357
+ return env;
3358
+ }
3359
+
3360
+ // The best identifier to look this server up by in the MCP Security Index: the
3361
+ // URL for a remote server, otherwise the launched package (skipping runners like
3362
+ // npx/uvx/node and flags), falling back to the server name.
3363
+ const MCP_RUNNERS = new Set(['npx', '-y', '--yes', 'uvx', 'uv', 'node', 'bun', 'deno', 'python', 'python3', '-m', 'pipx', 'run', 'npm', 'pnpm', 'yarn', 'dlx', 'bunx']);
3364
+ function mcpLookupId(server, name) {
3365
+ if (server.url) return String(server.url);
3366
+ const toks = [server.command, ...(server.args || [])].filter(Boolean).map(String);
3367
+ for (const t of toks) {
3368
+ if (MCP_RUNNERS.has(t) || t.startsWith('-')) continue;
3369
+ if (/^@?[\w][\w./-]*$/.test(t)) return t; // first package-ish token
3370
+ }
3371
+ return name;
3372
+ }
3373
+
3374
+ // Fetch a server's cached findings from the platform's MCP Security Index.
3375
+ // Best-effort — never throws to the caller; a timeout/offline just returns an error.
3376
+ async function mcpLookup(url, id) {
3377
+ const ctrl = new AbortController();
3378
+ const timer = setTimeout(() => ctrl.abort(), clampInt(process.env.SHOMRA_API_TIMEOUT_MS, 15000, 1000, 60000));
3379
+ try {
3380
+ const res = await fetch(`${url}/catalog/lookup?id=${encodeURIComponent(id)}`, {
3381
+ signal: ctrl.signal,
3382
+ headers: { Accept: 'application/json', 'User-Agent': 'shomra-agent' },
3383
+ });
3384
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
3385
+ return await res.json();
3386
+ } finally {
3387
+ clearTimeout(timer);
3388
+ }
3389
+ }
3390
+
3391
+ /** Turn an index lookup into an alert level, or null when it can't verdict. */
3392
+ function mcpIndexAlert(index) {
3393
+ if (!index || !index.found || !index.scanned) return null;
3394
+ if (index.verdict === 'FAIL' || (index.criticalCount ?? 0) > 0) return 'BLOCK';
3395
+ if (index.verdict === 'REVIEW' || (index.highCount ?? 0) > 0) return 'FLAG';
3396
+ return 'OK';
3397
+ }
3398
+
3399
+ /** Combine the local-gate verdict with the index alert (worst wins). */
3400
+ function worstMcpVerdict(local, idxAlert) {
3401
+ const rank = { ALLOW: 0, OK: 0, PASS: 0, FLAG: 1, REVIEW: 1, BLOCK: 2, FAIL: 2 };
3402
+ const label = ['ALLOW', 'FLAG', 'BLOCK'];
3403
+ return label[Math.max(rank[local] ?? 0, rank[idxAlert] ?? 0)];
3404
+ }
3405
+
3406
+ /**
3407
+ * `shomra mcp serve` — Shomra AS an MCP server (stdio JSON-RPC 2.0). Point any
3408
+ * MCP-capable agent (Claude Code, Cursor, Cline, ChatGPT desktop, …) at it and
3409
+ * the LLM can call Shomra's checks as native tools in its own loop: after it
3410
+ * edits files it can `shomra_check` / `shomra_scan_models`, then `shomra_fix`.
3411
+ * Each tool is a thin bridge to the corresponding CLI verb with `--json`, so it
3412
+ * reuses the exact same engine as the CLI and editor — one engine, another face.
3413
+ */
3414
+ async function cmdMcpServe(flags) {
3415
+ const { createInterface } = await import('node:readline');
3416
+ const { execFileSync } = await import('node:child_process');
3417
+ const { fileURLToPath } = await import('node:url');
3418
+ const SELF = fileURLToPath(import.meta.url);
3419
+ const cwd = flags.path ? path.resolve(String(flags.path)) : process.cwd();
3420
+
3421
+ const send = (msg) => process.stdout.write(JSON.stringify(msg) + '\n');
3422
+ const ok = (id, result) => send({ jsonrpc: '2.0', id, result });
3423
+ const fail = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
3424
+
3425
+ // Run a shomra subcommand in a child process and return its --json output. Our
3426
+ // verbs still print JSON on a non-zero (findings-found) exit, so read stdout in
3427
+ // both the success and error branches.
3428
+ const runJson = (args) => {
3429
+ const run = () => execFileSync(process.execPath, [SELF, ...args, '--json'], { encoding: 'utf8', cwd, maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
3430
+ let out;
3431
+ try { out = run(); } catch (e) { out = e.stdout ? String(e.stdout) : ''; if (!out) return { text: String(e.stderr || e.message || 'command failed') }; }
3432
+ try { return { data: JSON.parse(out) }; } catch { return { text: out }; }
3433
+ };
3434
+
3435
+ const TOOLS = [
3436
+ { name: 'shomra_check', description: 'Gate every AI artifact (MCP configs, skills, slash commands, hooks, rules files) under a path for security issues — local-first, no network needed. Returns findings with file, line, severity and verdict. Run this after editing AI artifacts.', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'File or directory to check (default: workspace root).' } } } },
3437
+ { name: 'shomra_scan_models', description: 'Detect the AI models the code loads (from_pretrained, hf_hub_download, SentenceTransformer, …) and look each up in the Shomra Model Index for known vulnerabilities. Returns each model\'s verdict, findings, and a safe-loading fix plan (kwargs to add to the load call). Run this after adding or changing model-loading code.', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'File or directory to scan (default: workspace root).' } } } },
3438
+ { name: 'shomra_fix', description: 'Generate a minimal security fix for one AI artifact. Returns the fixed content; set apply=true to write it to disk in place.', inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'Path to the artifact to fix.' }, apply: { type: 'boolean', description: 'Write the fix to disk (default: false — return it only).' } }, required: ['file'] } },
3439
+ { name: 'shomra_explain', description: 'Explain the findings in one AI artifact: why each matters, a one-line exploit, and an honest false-positive read.', inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'Path to the artifact to explain.' } }, required: ['file'] } },
3440
+ ];
3441
+
3442
+ const callTool = (name, args) => {
3443
+ const a = args || {};
3444
+ if (name === 'shomra_check') return runJson(['check', a.path ? String(a.path) : '.']);
3445
+ if (name === 'shomra_scan_models') return runJson(['models', a.path ? String(a.path) : '.']);
3446
+ if (name === 'shomra_fix') return runJson(['fix', String(a.file || ''), ...(a.apply ? ['--apply'] : [])]);
3447
+ if (name === 'shomra_explain') return runJson(['why', String(a.file || '')]);
3448
+ return { text: `Unknown tool: ${name}`, isError: true };
3449
+ };
3450
+
3451
+ const rl = createInterface({ input: process.stdin });
3452
+ rl.on('line', (line) => {
3453
+ const s = line.trim();
3454
+ if (!s) return;
3455
+ let msg;
3456
+ try { msg = JSON.parse(s); } catch { return; }
3457
+ const { id, method, params } = msg;
3458
+ try {
3459
+ if (method === 'initialize') return ok(id, { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'shomra', version: VERSION } });
3460
+ if (method === 'ping') return ok(id, {});
3461
+ if (method === 'tools/list') return ok(id, { tools: TOOLS });
3462
+ if (method === 'tools/call') {
3463
+ const res = callTool(params && params.name, params && params.arguments);
3464
+ const text = res.data !== undefined ? JSON.stringify(res.data, null, 2) : String(res.text != null ? res.text : '');
3465
+ return ok(id, { content: [{ type: 'text', text }], isError: !!res.isError });
3466
+ }
3467
+ if (typeof method === 'string' && method.startsWith('notifications/')) return; // no reply to notifications
3468
+ if (id !== undefined) return fail(id, -32601, `Method not found: ${method}`);
3469
+ } catch (e) {
3470
+ if (id !== undefined) return fail(id, -32603, String((e && e.message) || e));
3471
+ }
3472
+ });
3473
+ await new Promise((resolve) => rl.on('close', resolve));
3474
+ }
3475
+
3476
+ async function cmdMcp(flags, positional) {
3477
+ const sub = String(positional[0] || '').toLowerCase();
3478
+
3479
+ // `shomra mcp serve` — expose Shomra AS an MCP server so any LLM/coding agent
3480
+ // can call its checks as native tools (check / scan_models / fix / explain).
3481
+ if (sub === 'serve') return cmdMcpServe(flags);
3482
+
3483
+ const configFile = path.resolve(flags.config ? String(flags.config) : '.mcp.json');
3484
+
3485
+ if (sub === 'list') {
3486
+ const cfg = fs.existsSync(configFile) ? (() => { try { return JSON.parse(fs.readFileSync(configFile, 'utf8')); } catch { return {}; } })() : {};
3487
+ const servers = cfg.mcpServers || cfg.servers || {};
3488
+ const names = Object.keys(servers);
3489
+ if (flags.json) { console.log(JSON.stringify({ config: configFile, servers }, null, 2)); return; }
3490
+ console.log(bold(cyan('\n MCP servers')) + dim(` — ${path.relative(process.cwd(), configFile).split(path.sep).join('/')}`));
3491
+ if (!names.length) console.log(dim(' (none)\n'));
3492
+ else { for (const n of names) console.log(` ${green('●')} ${bold(n)} ${dim(servers[n].url || [servers[n].command, ...(servers[n].args || [])].filter(Boolean).join(' '))}`); console.log(''); }
3493
+ return;
3494
+ }
3495
+
3496
+ if (sub !== 'add') {
3497
+ console.error(red('✗') + ` Usage: ${bold('shomra mcp add <name> <command…> | --url <url>')} ${dim('|')} ${bold('shomra mcp list')}`);
3498
+ process.exit(1);
3499
+ }
3500
+
3501
+ const name = positional[1];
3502
+ if (!name) { console.error(red('✗') + ` Usage: ${bold('shomra mcp add <name> <command…>')}`); process.exit(1); }
3503
+ const server = {};
3504
+ if (flags.url) server.url = String(flags.url);
3505
+ const cmdTokens = flags.command ? String(flags.command).split(/\s+/) : positional.slice(2);
3506
+ if (cmdTokens.length) { server.command = cmdTokens[0]; if (cmdTokens.length > 1) server.args = cmdTokens.slice(1); }
3507
+ if (flags.env) server.env = parseEnvKV(flags.env);
3508
+ if (!server.url && !server.command) { console.error(red('✗') + ' Provide a launch command or --url.'); process.exit(1); }
3509
+
3510
+ // Vet the candidate BEFORE writing it anywhere: (1) local heuristics, then
3511
+ // (2) the platform's pre-scanned MCP Security Index (GET /catalog/lookup) so a
3512
+ // server already scanned in the sandbox contributes its real findings without
3513
+ // running Docker here. The index is best-effort — offline/unknown just falls
3514
+ // back to the local verdict. Skip the network with --no-index.
3515
+ const candidate = JSON.stringify({ mcpServers: { [name]: server } }, null, 2);
3516
+ const g = localGate(candidate, { kind: 'mcp', path: '.mcp.json' });
3517
+
3518
+ let index = null;
3519
+ if (!flags['no-index']) {
3520
+ try {
3521
+ const { url } = resolveSettings(loadConfig());
3522
+ index = await mcpLookup(url, mcpLookupId(server, name));
3523
+ } catch (e) {
3524
+ index = { error: e.message };
3525
+ }
3526
+ }
3527
+ const idxAlert = mcpIndexAlert(index);
3528
+ const verdict = worstMcpVerdict(g.verdict, idxAlert);
3529
+
3530
+ if (!flags.json) {
3531
+ console.log(bold(cyan(`\n Vetting MCP server "${name}"…`)));
3532
+ for (const f of g.findings.slice(0, 6)) console.log(` ${SEV_COLOR[f.severity](String(f.severity).padEnd(8))} ${f.title} ${dim('(local)')}`);
3533
+ if (index && index.found && index.scanned) {
3534
+ const vc = index.verdict === 'FAIL' ? red : index.verdict === 'REVIEW' ? yellow : green;
3535
+ console.log(dim(` ── MCP Security Index: `) + bold(index.slug) + dim(` · verdict `) + vc(String(index.verdict)) + dim(` · risk ${index.riskScore} ──`));
3536
+ for (const f of (index.findings || []).slice(0, 6)) console.log(` ${(SEV_COLOR[f.severity] || dim)(String(f.severity).padEnd(8))} ${f.title} ${dim('(index)')}`);
3537
+ printAlternatives(index.alternatives, 'mcp', ' ');
3538
+ } else if (index && index.found && !index.scanned) {
3539
+ console.log(dim(` MCP Security Index: found "${index.slug}" but it hasn't been scanned yet.`));
3540
+ } else if (index && index.error) {
3541
+ console.log(dim(` MCP Security Index: unavailable (${index.error}) — using local checks only.`));
3542
+ } else if (index) {
3543
+ console.log(dim(` MCP Security Index: not indexed — using local checks only.`));
3544
+ }
3545
+ }
3546
+ if (verdict === 'BLOCK' && !flags.force) {
3547
+ if (flags.json) console.log(JSON.stringify({ installed: false, verdict, local: g.verdict, index, findings: g.findings }, null, 2));
3548
+ else console.log(`\n ${red('✗ Blocked — not installed.')} ${dim('Review the findings, or override with')} ${bold('--force')}${dim('.')}\n`);
3549
+ process.exitCode = 1;
3550
+ return;
3551
+ }
3552
+
3553
+ // Merge into the target config.
3554
+ let cfg = {};
3555
+ if (fs.existsSync(configFile)) { try { cfg = JSON.parse(fs.readFileSync(configFile, 'utf8')); } catch { console.error(red('✗') + ` ${configFile} is not valid JSON.`); process.exit(1); } }
3556
+ cfg.mcpServers = cfg.mcpServers || {};
3557
+ const existed = !!cfg.mcpServers[name];
3558
+ cfg.mcpServers[name] = server;
3559
+ fs.mkdirSync(path.dirname(configFile), { recursive: true });
3560
+ fs.writeFileSync(configFile, JSON.stringify(cfg, null, 2) + '\n');
3561
+
3562
+ const rel = path.relative(process.cwd(), configFile).split(path.sep).join('/');
3563
+ if (flags.json) { console.log(JSON.stringify({ installed: true, name, verdict, local: g.verdict, index, config: rel, updated: existed }, null, 2)); return; }
3564
+ const note = verdict === 'FLAG' ? yellow(' (flagged — review the findings above)') : verdict === 'BLOCK' ? red(' (forced past a BLOCK)') : green(' ✓ clean');
3565
+ console.log(`\n ${green(existed ? '✓ Updated' : '✓ Added')} MCP server ${bold(name)} ${dim('→ ' + rel)}${note}\n`);
3566
+ }
3567
+
3568
+ // ── shomra secrets: did I leak a key — now, or ever? ─────────────────────────
3569
+ //
3570
+ // shomra secrets [dir] [--history] [--depth N] [--json]
3571
+ //
3572
+ // Scans the working tree for live credentials, and with --history walks git
3573
+ // history too — a key deleted from HEAD but still reachable in an old commit is
3574
+ // still compromised and must be rotated. Uses the same SECRET_PATTERNS as the
3575
+ // gate. Nothing is sent anywhere; matched values are redacted in the output.
3576
+ function redactSecret(s) {
3577
+ const t = String(s).trim();
3578
+ return t.length <= 8 ? t[0] + '••••' : `${t.slice(0, 4)}…${t.slice(-2)}`;
3579
+ }
3580
+ function isGitRepo(root) {
3581
+ try { execSync('git rev-parse --is-inside-work-tree', { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }); return true; }
3582
+ catch { return false; }
3583
+ }
3584
+ // Walk the working tree for text files (skipping .git / vendored dirs), so an
3585
+ // UNTRACKED .env — the likeliest place a live secret sits — is scanned too.
3586
+ function walkFiles(root, cap = 8000) {
3587
+ const found = [];
3588
+ const stack = [root];
3589
+ while (stack.length && found.length < cap) {
3590
+ const dir = stack.pop();
3591
+ let entries;
3592
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
3593
+ for (const ent of entries) {
3594
+ if (ent.isDirectory()) { if (!SKIP_DIRS.has(ent.name) && ent.name !== '.git') stack.push(path.join(dir, ent.name)); continue; }
3595
+ found.push(path.relative(root, path.join(dir, ent.name)).split(path.sep).join('/'));
3596
+ if (found.length >= cap) break;
3597
+ }
3598
+ }
3599
+ return found;
3600
+ }
3601
+ // Stream git history, flagging secret-shaped tokens in ADDED lines. Bounded by depth.
3602
+ function scanGitHistory(root, depth) {
3603
+ let out;
3604
+ try { out = execSync(`git log --all -p -n ${depth} --no-color --format="commit %H %an %ad"`, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 128 * 1024 * 1024 }).toString(); }
3605
+ catch { return null; }
3606
+ const hits = [];
3607
+ const seen = new Set();
3608
+ let commit = '', file = '';
3609
+ for (const line of out.split(/\r?\n/)) {
3610
+ if (line.startsWith('commit ')) { commit = line.slice(7, 19); continue; }
3611
+ if (line.startsWith('+++ b/')) { file = line.slice(6); continue; }
3612
+ if (line[0] !== '+' || line.startsWith('+++')) continue;
3613
+ const added = line.slice(1);
3614
+ for (const { name, re } of SECRET_PATTERNS) {
3615
+ const m = added.match(re);
3616
+ if (!m) continue;
3617
+ const key = `${commit}:${file}:${name}`;
3618
+ if (seen.has(key)) continue;
3619
+ seen.add(key);
3620
+ hits.push({ where: 'history', commit, file, secret: name, sample: redactSecret(m[0]) });
3621
+ }
3622
+ }
3623
+ return hits;
3624
+ }
3625
+ function cmdSecrets(flags, positional) {
3626
+ const root = path.resolve(positional[0] || '.');
3627
+ const hits = [];
3628
+ const isGit = isGitRepo(root);
3629
+ for (const rel of walkFiles(root)) {
3630
+ let content;
3631
+ try {
3632
+ const full = path.join(root, rel);
3633
+ if (fs.statSync(full).size > MAX_ARTIFACT_BYTES) continue;
3634
+ content = fs.readFileSync(full, 'utf8');
3635
+ } catch { continue; }
3636
+ if (content.includes('\0')) continue; // skip binary
3637
+ for (const f of localScan(content, { categories: ['secret'] }).findings) {
3638
+ hits.push({ where: 'working-tree', file: rel, line: f.line, secret: f.label.replace(/^Live credential:\s*/, '') });
3639
+ }
3640
+ }
3641
+ let history = null;
3642
+ if (flags.history) {
3643
+ history = scanGitHistory(root, clampInt(flags.depth, 300, 1, 5000));
3644
+ if (history) hits.push(...history);
3645
+ }
3646
+
3647
+ if (flags.json) { console.log(JSON.stringify({ workingTree: hits.filter((h) => h.where === 'working-tree').length, history: history ? history.length : null, hits }, null, 2)); return; }
3648
+
3649
+ console.log(bold(cyan('\n Shomra secrets')) + dim(` — ${path.relative(process.cwd(), root).split(path.sep).join('/') || '.'}${flags.history ? ' · working tree + git history' : ' · working tree'}`));
3650
+ if (!isGit) console.log(dim(' (not a git repo — working tree only; --history unavailable)'));
3651
+ else if (!flags.history) console.log(dim(' Tip: add ') + bold('--history') + dim(' to also scan past commits (a leaked key removed from HEAD is still live).'));
3652
+ const wt = hits.filter((h) => h.where === 'working-tree');
3653
+ const hi = hits.filter((h) => h.where === 'history');
3654
+ if (!hits.length) { console.log(green('\n ✓ No secret-shaped values found.\n')); return; }
3655
+ if (wt.length) {
3656
+ console.log(red(`\n ${wt.length} in the working tree:`));
3657
+ for (const h of wt.slice(0, 25)) console.log(` ${red('●')} ${bold(h.file)}${h.line ? dim(':' + h.line) : ''} ${dim(h.secret)}`);
3658
+ }
3659
+ if (hi.length) {
3660
+ console.log(yellow(`\n ${hi.length} in git history ${dim('(rotate — still reachable):')}`));
3661
+ for (const h of hi.slice(0, 25)) console.log(` ${yellow('●')} ${dim(h.commit)} ${bold(h.file)} ${dim(h.secret + ' ' + (h.sample || ''))}`);
3662
+ }
3663
+ console.log(dim(`\n Rotate every matched credential now. A committed secret is compromised even after you delete it — history keeps it.\n`));
3664
+ process.exitCode = 1;
3665
+ }
3666
+
3667
+ // ── shomra models: which models does my code load — and are they safe? ───────
3668
+ //
3669
+ // shomra models [dir] [--strict] [--json] [--dry-run]
3670
+ //
3671
+ // Scans source for the AI models the code loads (from_pretrained / SentenceTransformer
3672
+ // / hf_hub_download / huggingface.co URLs / torch.hub.load / ollama), then looks each
3673
+ // one up in the platform's Model Security Index (GET /models/lookup) and ALERTS on
3674
+ // known vulnerabilities. You can't scan a model's weights from source — but the
3675
+ // platform already scraped + scanned the popular ones, so a reference is enough.
3676
+ // Pinned revisions (revision=) are looked up by exact commit sha. `--dry-run` shows
3677
+ // what it detected + the lookup URLs without calling the API.
3678
+ // On-machine cache of index verdicts (~/.shomra/model-cache.json) so the model
3679
+ // check is LOCAL-FIRST: after the first sight a known-vulnerable model resolves
3680
+ // with no network — offline, and unaffected by a slow/down/flapping backend.
3681
+ // Only positive hits (found) are cached (misses re-check when online); a stale
3682
+ // hit is still served when the backend is unreachable. SHOMRA_MODEL_CACHE=0 off.
3683
+ const MODEL_CACHE_FILE = path.join(CONFIG_DIR, 'model-cache.json');
3684
+ function modelCacheOff() { return process.env.SHOMRA_MODEL_CACHE === '0' || String(process.env.SHOMRA_MODEL_CACHE).toLowerCase() === 'false'; }
3685
+ function loadModelCache() { try { return JSON.parse(fs.readFileSync(MODEL_CACHE_FILE, 'utf8')) || {}; } catch { return {}; } }
3686
+ function saveModelCache(c) { try { fs.mkdirSync(CONFIG_DIR, { recursive: true }); fs.writeFileSync(MODEL_CACHE_FILE, JSON.stringify(c)); } catch { /* cache is best-effort */ } }
3687
+
3688
+ async function modelLookup(url, id, sha) {
3689
+ const key = `${id}@${sha || 'latest'}`;
3690
+ const ttl = clampInt(process.env.SHOMRA_MODEL_CACHE_TTL_MS, 7 * 24 * 3600 * 1000, 0, 365 * 24 * 3600 * 1000);
3691
+ const cache = modelCacheOff() ? {} : loadModelCache();
3692
+ const hit = cache[key];
3693
+ // Fresh cache hit → fully local, no network (works offline, ignores the breaker).
3694
+ if (hit && hit.cachedAt && Date.now() - hit.cachedAt < ttl) return { ...hit.data, cached: true };
3695
+ // No backend configured → the Model Index is enrichment only. Serve a cached
3696
+ // hit if we have one, else signal "not looked up" (callers treat it as offline).
3697
+ if (!url) {
3698
+ if (hit && hit.data) return { ...hit.data, cached: true, stale: true };
3699
+ throw new Error('model index not configured (set SHOMRA_URL to enrich)');
3700
+ }
3701
+ // Backend known-down (breaker open) → serve a stale hit rather than stalling.
3702
+ if (breakerOpen()) {
3703
+ if (hit && hit.data) return { ...hit.data, cached: true, stale: true };
3704
+ throw new Error('backend unavailable (circuit open)');
3705
+ }
3706
+
3707
+ const q = `id=${encodeURIComponent(id)}${sha ? `&sha=${encodeURIComponent(sha)}` : ''}`;
3708
+ const ctrl = new AbortController();
3709
+ const timer = setTimeout(() => ctrl.abort(), clampInt(process.env.SHOMRA_API_TIMEOUT_MS, 15000, 1000, 60000));
3710
+ try {
3711
+ const res = await fetch(`${url}/models/lookup?${q}`, { signal: ctrl.signal, headers: { Accept: 'application/json', 'User-Agent': 'shomra-agent' } });
3712
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
3713
+ const data = await res.json();
3714
+ breakerReset();
3715
+ if (!modelCacheOff() && data && data.found) { cache[key] = { cachedAt: Date.now(), data }; saveModelCache(cache); }
3716
+ return data;
3717
+ } catch (e) {
3718
+ breakerTrip();
3719
+ if (hit && hit.data) return { ...hit.data, cached: true, stale: true }; // stale beats nothing
3720
+ throw e;
3721
+ } finally { clearTimeout(timer); }
3722
+ }
3723
+ const MODEL_SEV_RANK = { CRITICAL: 5, HIGH: 4, MEDIUM: 3, LOW: 2, INFO: 1 };
3724
+
3725
+ // Print the platform's "use this instead" suggestions for a flagged model / MCP
3726
+ // server — the safer, lower-risk peers in the same category the index folds into
3727
+ // a lookup. Deterministic, no AI. `kind` picks the id + URL fields to show.
3728
+ function printAlternatives(alts, kind, indent = ' ') {
3729
+ if (!Array.isArray(alts) || !alts.length) return;
3730
+ console.log(`${indent}${green('↳ safer alternatives')} ${dim('(same category, lower risk):')}`);
3731
+ for (const a of alts.slice(0, 5)) {
3732
+ const id = kind === 'model' ? a.modelId : (a.packageName || a.name);
3733
+ const url = kind === 'model' ? a.url : (a.repoUrl || a.homepage || '');
3734
+ const vc = a.verdict === 'FAIL' ? red : a.verdict === 'REVIEW' ? yellow : green;
3735
+ const label = id && id !== a.name ? `${bold(a.name)} ${dim('(' + id + ')')}` : bold(a.name);
3736
+ console.log(`${indent} ${green('•')} ${label} ${vc(String(a.verdict || '—'))} ${dim('risk ' + (a.riskScore ?? '?') + '/100')}${url ? dim(' · ' + url) : ''}`);
3737
+ }
3738
+ }
3739
+
3740
+ // Turn a flagged HF model's findings into the safe-loading kwargs to add to its
3741
+ // `from_pretrained(...)` call. Deterministic — no AI. Each kwarg carries the
3742
+ // reason it's recommended so the editor QuickPick / agent can explain the choice.
3743
+ function modelFixPlan(findings, sha) {
3744
+ const text = (findings || []).map((f) => `${f.title || ''} ${f.description || ''} ${f.class || ''} ${f.surface || ''} ${f.remediation || ''}`).join(' ').toLowerCase();
3745
+ const kwargs = [];
3746
+ if (/pickle|\.bin\b|hdf5|\.h5\b|keras|serial|safetensors/.test(text)) {
3747
+ kwargs.push({ name: 'use_safetensors', value: 'True', reason: 'Load safetensors instead of pickle/HDF5 weights, which can execute code the moment they load.' });
3748
+ }
3749
+ // Only for findings that are actually about the repo shipping executable code
3750
+ // (not generic "pickle executes arbitrary code" prose, which safetensors fixes).
3751
+ if (/trust_remote_code|auto_map|remote code|custom (python )?code|modeling_[\w.]+\.py/.test(text)) {
3752
+ kwargs.push({ name: 'trust_remote_code', value: 'False', reason: "Never run the model repo's own Python during load." });
3753
+ }
3754
+ if (sha) {
3755
+ kwargs.push({ name: 'revision', value: JSON.stringify(String(sha).slice(0, 40)), reason: 'Pin to the exact revision Shomra reviewed instead of a mutable branch (supply-chain).' });
3756
+ }
3757
+ return kwargs.length ? { kwargs } : null;
3758
+ }
3759
+
3760
+ async function cmdModels(flags, positional) {
3761
+ const cfg = loadConfig();
3762
+ const { url } = resolveSettings(cfg);
3763
+ const target = path.resolve(positional[0] || flags.path || '.');
3764
+ const dryRun = !!flags['dry-run'];
3765
+
3766
+ // Accept either a directory (walk it) or a single file. The editor extension
3767
+ // scans just the file you saved, so `shomra models <file>` must work as well.
3768
+ let root = target;
3769
+ let entries;
3770
+ try {
3771
+ const st = fs.statSync(target);
3772
+ if (st.isFile()) { root = path.dirname(target); entries = [path.basename(target)]; }
3773
+ else entries = walkFiles(root);
3774
+ } catch { entries = []; }
3775
+
3776
+ // 1. Detect model references across the repo's source, deduped by (id, sha).
3777
+ const refs = new Map(); // key → { id, sha, source, locations: [{file,line,via}] }
3778
+ for (const rel of entries) {
3779
+ if (!isModelRefScannable(rel)) continue;
3780
+ let content;
3781
+ try { const full = path.join(root, rel); if (fs.statSync(full).size > MAX_ARTIFACT_BYTES) continue; content = fs.readFileSync(full, 'utf8'); } catch { continue; }
3782
+ if (content.includes('\0')) continue;
3783
+ for (const r of scanModelRefs(content, rel)) {
3784
+ const key = `${r.source}:${r.id}:${r.revision || ''}`;
3785
+ if (!refs.has(key)) refs.set(key, { id: r.id, sha: r.revision || null, source: r.source, locations: [] });
3786
+ refs.get(key).locations.push({ file: r.file, line: r.line, via: r.via });
3787
+ }
3788
+ }
3789
+ const unique = [...refs.values()];
3790
+
3791
+ if (!unique.length) {
3792
+ if (flags.json) console.log(JSON.stringify({ detected: 0, models: [] }, null, 2));
3793
+ else console.log(green('\n ✓ No AI model references found in the code.') + dim(` (looked under ${path.relative(process.cwd(), root).split(path.sep).join('/') || '.'})`) + '\n');
3794
+ return;
3795
+ }
3796
+
3797
+ if (dryRun) {
3798
+ if (flags.json) { console.log(JSON.stringify({ detected: unique.length, models: unique.map((u) => ({ id: u.id, sha: u.sha, source: u.source, lookup: `${url}/models/lookup?id=${encodeURIComponent(u.id)}${u.sha ? '&sha=' + u.sha : ''}`, locations: u.locations })) }, null, 2)); return; }
3799
+ console.log(bold(cyan('\n Shomra models')) + dim(` — ${unique.length} reference(s) detected (dry run — no lookup)`));
3800
+ for (const u of unique) console.log(` ${gray('•')} ${bold(u.id)}${u.sha ? dim('@' + u.sha) : ''} ${dim('(' + u.source + ')')} ${dim('→ ' + url + '/models/lookup?id=' + u.id)}`);
3801
+ console.log('');
3802
+ return;
3803
+ }
3804
+
3805
+ // 2. Look each up in the Model Security Index (bounded-parallel).
3806
+ const looked = new Array(unique.length).fill(null);
3807
+ let apiDown = false;
3808
+ const conc = clampInt(process.env.SHOMRA_GATE_CONCURRENCY, 6, 1, 16);
3809
+ let next = 0;
3810
+ const worker = async () => {
3811
+ while (true) {
3812
+ const i = next++;
3813
+ if (i >= unique.length || apiDown) return;
3814
+ // ollama-runtime ids (no org/name) aren't in the HF-oriented index; skip lookup.
3815
+ if (unique[i].source === 'ollama') { looked[i] = { found: false, local: true }; continue; }
3816
+ try { looked[i] = await modelLookup(url, unique[i].id, unique[i].sha); }
3817
+ catch (e) { apiDown = true; looked[i] = { error: e.message }; }
3818
+ }
3819
+ };
3820
+ await Promise.all(Array.from({ length: Math.min(conc, unique.length) }, worker));
3821
+
3822
+ // 3. Classify + alert.
3823
+ const models = unique.map((u, i) => {
3824
+ const r = looked[i] || {};
3825
+ const findings = r.findings || [];
3826
+ const worst = findings.reduce((m, f) => Math.max(m, MODEL_SEV_RANK[f.severity] || 0), 0);
3827
+ const alert = r.found && (r.verdict === 'FAIL' || worst >= MODEL_SEV_RANK.CRITICAL) ? 'BLOCK'
3828
+ : r.found && (r.verdict === 'REVIEW' || worst >= MODEL_SEV_RANK.HIGH) ? 'FLAG'
3829
+ : 'OK';
3830
+ // A deterministic remediation plan for a flagged HF load: the safe-loading
3831
+ // kwargs to add to the `from_pretrained(...)` call, chosen from what the model
3832
+ // was flagged for. The editor turns this into a "Harden this model load"
3833
+ // quick-fix; the MCP `shomra_fix` tool returns it for an agent to apply.
3834
+ const fix = r.found && alert !== 'OK' && u.source === 'hf' ? modelFixPlan(findings, r.sha) : null;
3835
+ // Safer, lower-risk models in the same category the index folded into the
3836
+ // lookup — the "use this instead" fix for a vulnerable model reference.
3837
+ const alternatives = alert !== 'OK' ? (r.alternatives || []) : [];
3838
+ return { ...u, found: !!r.found, verdict: r.verdict || null, riskScore: r.riskScore ?? null, scannedSha: r.sha || null, findingCount: findings.length, findings, alert, fix, alternatives, error: r.error, notIndexed: !r.found && !r.error };
3839
+ });
3840
+ const blocked = models.filter((m) => m.alert === 'BLOCK').length;
3841
+ const flagged = models.filter((m) => m.alert === 'FLAG').length;
3842
+
3843
+ if (flags.json) {
3844
+ console.log(JSON.stringify({ detected: models.length, blocked, flagged, apiDown, url, models }, null, 2));
3845
+ } else {
3846
+ console.log(bold(cyan('\n Shomra models')) + dim(` — ${models.length} model reference(s)${url ? ` · index at ${url}` : ' · local detection only'}`));
3847
+ if (apiDown) console.log(` ${yellow('⚠')} ${dim(url ? 'Model index unreachable — could not fetch vulnerability info.' : 'Model index not configured — set SHOMRA_URL to check models against the Shomra Model Index.')}`);
3848
+ for (const m of models) {
3849
+ const mark = m.alert === 'BLOCK' ? red('●') : m.alert === 'FLAG' ? yellow('●') : m.notIndexed ? gray('○') : green('●');
3850
+ const status = m.error ? yellow('lookup failed')
3851
+ : m.notIndexed ? dim(m.source === 'ollama' ? 'local runtime — not in the index' : 'not in the index yet')
3852
+ : `${m.verdict === 'FAIL' ? red(m.verdict) : m.verdict === 'REVIEW' ? yellow(m.verdict) : green(m.verdict)} ${dim('risk ' + m.riskScore + ' · ' + m.findingCount + ' vuln(s)')}`;
3853
+ console.log(` ${mark} ${bold(m.id)}${m.sha ? dim('@' + String(m.sha).slice(0, 12)) : ''} ${dim('(' + m.source + ')')} ${status}`);
3854
+ console.log(` ${dim('used in ' + m.locations.slice(0, 3).map((l) => l.file + ':' + l.line).join(', ') + (m.locations.length > 3 ? ` (+${m.locations.length - 3})` : ''))}`);
3855
+ for (const f of m.findings.slice(0, 3)) console.log(` ${(SEV_COLOR[f.severity] || dim)(String(f.severity).padEnd(8))} ${f.title}`);
3856
+ printAlternatives(m.alternatives, 'model');
3857
+ if (m.notIndexed && m.source !== 'ollama') console.log(` ${dim('→ scan it now:')} ${bold('shomra model-scan ' + m.id)}`);
3858
+ }
3859
+ console.log(
3860
+ '\n ' + (blocked ? red(`✗ ${blocked} vulnerable`) + dim(` · ${flagged} to review`) : flagged ? yellow(`⚠ ${flagged} to review`) : green('✓ No known-vulnerable models')) + '\n',
3861
+ );
3862
+ }
3863
+
3864
+ if (blocked) process.exitCode = 1;
3865
+ else if (flagged && flags.strict) process.exitCode = 2;
3866
+ }
3867
+
3868
+ function cmdHelp() {
3869
+ console.log(`
3870
+ ${bold(cyan('Shomra'))} ${dim('— AI security agent v' + VERSION)}
3871
+
3872
+ ${bold('USAGE')}
3873
+ shomra <command> [options]
3874
+
3875
+ ${bold('MODES')} ${dim('— local-first: everything that can run on your machine does, with no account')}
3876
+ ${cyan('Local')} ${dim('(no key)')} check · gate · doctor · protect · secrets · models · new · mcp add
3877
+ ${dim('Fully on-machine. Nothing leaves your machine. Your lead-in — no signup.')}
3878
+ ${green('Enrolled')} ${dim('(shm_live_)')} adds org policy, AI ${bold('fix')}/${bold('why')}, deep scans (zip/model/memory) & the dashboard
3879
+ ${green('CI')} ${dim('(shm_ci_)')} scoped, revocable pipeline key for ${bold('pr')} / ${bold('check')} in CI
3880
+ ${dim('Enroll with')} ${bold('shomra init --key shm_…')}${dim('; generate keys in the platform → Settings → API Keys.')}
3881
+
3882
+ ${bold('COMMANDS')}
3883
+ ${dim('Daily — the verbs you live in')}
3884
+ ${cyan('check')} ${bold('Is my repo safe?')} Gate every AI artifact ${dim('[dir] [--staged|--changed] [--fix] [--strict] [--json]')}
3885
+ ${cyan('fix')} Remediate an artifact in place (AI) ${dim('<file> [--apply] [--kind …] [--json]')}
3886
+ ${cyan('why')} Explain a finding + false-positive read ${dim('<file> [--kind …] [--json]')}
3887
+ ${cyan('gate')} Vet ONE AI artifact before install ${dim('<file> [--kind …] [--strict] [--json] · --all for a whole repo (CI)')}
3888
+ ${cyan('scan')} Discover AI tooling on this machine ${dim('[--report] [--json] [--path <dir>]')}
3889
+ ${cyan('status')} Show config, enrollment + firewall health
3890
+
3891
+ ${dim('Setup — run once per machine / repo')}
3892
+ ${cyan('init')} Configure + enroll this machine ${dim('--key shm_live_… [--url <backend>]')}
3893
+ ${cyan('protect')} Wire the runtime firewall for every coding agent ${dim('[--local] [--force]')}
3894
+ ${cyan('install-hook')} Wire the runtime firewall into ONE agent ${dim('[--agent claude|cursor|windsurf|gemini|codex|copilot|cline|aider|all] [--global]')}
3895
+ ${cyan('install-precommit')} Gate staged AI artifacts on git commit ${dim('[dir] [--force]')}
3896
+ ${cyan('doctor')} ${bold('Am I safe?')} Posture of this machine's AI setup ${dim('[--json]')}
3897
+
3898
+ ${dim('CI & repo hygiene')}
3899
+ ${cyan('pr')} Review a PR — inline findings on the diff ${dim('(CI) [--init] [--strict] [--dry-run]')}
3900
+ ${cyan('baseline')} Accept current findings; only NEW ones fail ${dim('[dir]')}
3901
+ ${cyan('secrets')} Scan working tree + git history for leaked keys ${dim('[dir] [--history] [--depth N]')}
3902
+ ${cyan('models')} Find models the code loads + look up known vulns ${dim('[dir] [--strict] [--dry-run]')}
3903
+
3904
+ ${dim('Build safely')}
3905
+ ${cyan('new')} Scaffold a secure-by-default artifact ${dim('skill|command|subagent|agent-card|mcp|rules [name]')}
3906
+ ${cyan('mcp add')} Vet an MCP server, then add it to a config ${dim('<name> <command…>|--url <url> [--config <f>] [--force]')}
3907
+ ${cyan('mcp serve')} Run Shomra AS an MCP server so agents call its checks ${dim('(check/scan_models/fix/explain tools)')}
3908
+
3909
+ ${dim('Governance & advanced')} ${dim('→')} ${bold('shomra admin')} ${dim('for the full list')}
3910
+ ${cyan('admin')} Deep scans, red-team, hardening, agent identity, LLM proxy
3911
+ ${dim('scan-zip · model-scan · memory-scan · redteam · campaign · harden · agent-identity · llm-proxy')}
3912
+
3913
+ ${dim('(internal hook handlers, invoked by install-hook — not run by hand: tool-guard, result-guard)')}
3914
+
3915
+ ${bold('GATE')}
3916
+ Checks an MCP config / Skill / slash command / hook / rules file BEFORE it
3917
+ lands on the machine. Exit 0 = allowed, 1 = blocked (2 = flagged with --strict)
3918
+ — wire it into pre-commit or CI. Nothing is executed; analysis is static.
3919
+
3920
+ ${bold('Works offline.')} Real static analysis (dangerous shell, prompt injection,
3921
+ secrets, exfil sinks, over-permissioned tool grants, install-lure prose) runs
3922
+ ON-MACHINE, so ${bold('gate')} returns a genuine verdict with no backend and no key.
3923
+ When enrolled + reachable, the backend layers your ORG POLICY + governance on
3924
+ top. If the backend is down it falls back to the local verdict (and says so);
3925
+ ${bold('--strict')} instead fails closed (exit 1) because org policy couldn't be verified.
3926
+
3927
+ ${bold('--all')} walks a repo/dir and gates every AI artifact at once — drop it in
3928
+ a CI job to fail the build on risky artifacts. CI environment (provider, repo,
3929
+ branch, commit) is auto-detected and recorded for local-vs-CI gate activity.
3930
+
3931
+ ${bold('CHECK')} ${dim('— the one command a developer runs')}
3932
+ ${bold('shomra check')} answers "is my repo safe?" in one shot: it finds every AI
3933
+ artifact in the tree (MCP configs, Skills, slash commands, hooks, rules files)
3934
+ and gates them together, ${bold('local-first')} — a real on-machine verdict with no
3935
+ backend or key; enrolling layers your org policy on top. It is ${bold('gate --all')}
3936
+ with dev ergonomics:
3937
+ ${dim('shomra check')} every AI artifact under the repo
3938
+ ${dim('shomra check --staged')} only what's git-staged ${dim('(wire into pre-commit / on-save)')}
3939
+ ${dim('shomra check --changed')} only what changed vs HEAD
3940
+ ${dim('shomra check --fix')} gate, then remediate what isn't clean, in place
3941
+ ${dim('shomra check --json')} machine-readable — what an IDE extension calls
3942
+ ${dim('shomra check --sarif')} SARIF 2.1.0 — upload for native GitHub/GitLab PR annotations
3943
+ Exit 0 = clean, 1 = blocked, 2 = flagged with --strict.
3944
+
3945
+ ${bold('BASELINE & SUPPRESSION')} ${dim('— adopt on a messy repo; silence a false positive')}
3946
+ ${bold('shomra baseline')} records the current findings as accepted (.shomra/baseline.json,
3947
+ line-independent) so only findings introduced AFTER it fail — commit it to share
3948
+ with the team. Silence individual findings three ways:
3949
+ ${dim('.shomraignore')} a repo file: ${dim('path/glob')} (skip file) or ${dim('path/glob :: title-substring')}
3950
+ ${dim('inline comment')} ${bold('// shomra-ignore')} / ${bold('# shomra-ignore')} on the finding's line or the one above
3951
+ ${dim('whole file')} ${bold('shomra-ignore-file')} in the first lines (works in JSON too)
3952
+ Any suppression re-grades the artifact, so a fully-suppressed file drops to ALLOW.
3953
+ ${dim('--no-suppress')} ignores all of the above; ${dim('--no-baseline')} ignores just the baseline.
3954
+
3955
+ ${bold('POLICY-AS-CODE')} ${dim('— team gate rules, versioned in the repo')}
3956
+ ${bold('.shomra/policy.yml')} (or .json) sets your team's thresholds, reviewed in PRs:
3957
+ ${dim('block: high')} min severity that BLOCKS ${dim('(critical|high|medium|low|none)')}
3958
+ ${dim('flag: medium')} min severity that FLAGS
3959
+ ${dim('allow: ["IPv4 address"]')} finding titles to always downgrade
3960
+ For a local verdict the repo policy fully re-grades; when the backend returned an
3961
+ org decision it can only make it STRICTER (worst-wins). ${dim('--no-policy')} skips it.
3962
+
3963
+ ${bold('FIX')} ${dim('— remediate without leaving your editor')}
3964
+ ${bold('shomra fix <file>')} generates a MINIMAL fix for whatever the gate flags in
3965
+ that artifact and shows it as a unified diff; ${bold('--apply')} writes it back to the
3966
+ local file. The fix is produced on the platform with your org's AI key (so no
3967
+ provider key sits on the dev machine) — enrollment is required. When the
3968
+ server has no AI configured it degrades to printing the deterministic
3969
+ remediation guidance to apply by hand. Nothing is committed or pushed; the
3970
+ edit lands in your working tree for you to review and commit.
3971
+
3972
+ ${bold('WHY')} ${dim('— decide if a finding is real')}
3973
+ ${bold('shomra why <file>')} is the developer shape of "investigate": for each finding
3974
+ it gives a plain-English why-it-matters, a one-line exploit scenario, and an
3975
+ honest true/false-positive read — the conclusion, not a tool-call timeline.
3976
+ AI-distilled when enrolled; offline it prints the on-machine findings and their
3977
+ fixes. Use it when the gate flags something you think is a false positive.
3978
+
3979
+ ${bold('INSTALL-PRECOMMIT')}
3980
+ ${bold('shomra install-precommit')} writes a ${dim('.git/hooks/pre-commit')} that runs
3981
+ ${bold('check --staged')}, so a risky MCP config / skill / rules file is caught before
3982
+ it commits. A BLOCK stops the commit; flags warn but don't. Existing hooks are
3983
+ never clobbered (it tells you the one line to add, or ${bold('--force')} replaces with
3984
+ a backup). Override a single commit with ${bold('git commit --no-verify')}.
3985
+
3986
+ ${bold('MODEL-SCAN')}
3987
+ Runs SAST over a public AI model's SOURCE — the custom .py files transformers
3988
+ imports under trust_remote_code and the config.json/tokenizer that bind them.
3989
+ Flags eval/exec/os.system/subprocess, pickle/torch.load deserialization,
3990
+ __reduce__ gadgets, network egress and auto_map (AutoModel/AutoTokenizer)
3991
+ usage, each with a rule id, file:line and code snippet. Weights are never
3992
+ downloaded and nothing is executed. Findings land in your Shomra dashboard.
3993
+
3994
+ ${bold('MEMORY-SCAN')}
3995
+ Persistent agent memory (MEMORY.md, .claude/memory/…, mem0/letta stores) AND
3996
+ rules/instruction files (CLAUDE.md, AGENTS.md, .cursorrules, copilot-instructions,
3997
+ …) are re-fed to the model as trusted context every session — so a single
3998
+ poisoned entry (OWASP ASI06 / the MemoryTrap class) persists across sessions and
3999
+ reboots. Rules files are graded against an instruction baseline (standing
4000
+ directives are legitimate there; only hijack / conceal-from-user / staged-payload
4001
+ / exfil phrasing is poison), memory against a fact baseline. memory-scan reports
4002
+ each write (with provenance) so Shomra can track drift from an approved baseline
4003
+ and roll back a poisoned store. Once ${bold('install-hook')} is wired, the agent's own
4004
+ memory and rules-file writes are captured automatically. Analysis is static.
4005
+
4006
+ ${bold('REDTEAM')}
4007
+ Replays a library of adversarial scenarios (goal hijack, indirect injection,
4008
+ system-prompt leak, data exfil, tool escalation, jailbreak, secret extraction,
4009
+ memory poisoning) against your OWN LLM Guard (in probe mode — never logged as a
4010
+ real attack) or model, scores a resilience %, and flags REGRESSIONS vs the last
4011
+ run. Authorized testing of your own stack; nothing is executed and no attack
4012
+ leaves the platform. Add ${bold('--evolve')} to turn on the evolutionary attacker: a
4013
+ population-based genetic search that breeds evasive variants (obfuscation,
4014
+ encoding, wrapping, splitting) against any scenario the fixed set can't crack,
4015
+ learning what beats YOUR guard and opening with it next time. Works with AI on
4016
+ or off. In CI, gate the pipeline with ${bold('--min <resilience>')} and/or
4017
+ ${bold('--fail-on-regression')} (exit 2 fails the build). Run it on a schedule so a model
4018
+ or policy change can't silently weaken a defense.
4019
+
4020
+ ${bold('HARDEN')}
4021
+ The self-hardening flywheel — turns a red-team breach into a defense. Runs a
4022
+ red-team (or reuses one with ${bold('--run <id>')}), asks Shomra to propose high-precision
4023
+ detection signatures for whatever got through, and VERIFIES each against a
4024
+ benign corpus: a candidate must catch the attack AND fire on zero legitimate
4025
+ messages, so the guard can only ever get tighter. With ${bold('--apply')} the survivors
4026
+ go live as a signature pack — no redeploy — and a confirmation re-run proves
4027
+ the resilience lift. Without AI configured it still works, mining signatures
4028
+ deterministically from the breaching prompts. Pair it with ${bold('redteam')} in CI.
4029
+
4030
+ ${bold('AGENT IDENTITY')}
4031
+ Give each non-human agent a first-class identity with a least-privilege
4032
+ capability policy — which providers/models it may call, which tools / MCP
4033
+ servers it may invoke, whether it may run shell. ${bold('agent-identity register')} mints
4034
+ its shm_agt_ credential; export ${bold('SHOMRA_AGENT')} so ${bold('llm-proxy')} and the runtime
4035
+ firewall present it, and every call is authorized against its policy at the two
4036
+ runtime chokepoints (identity axis) on top of content screening. Govern,
4037
+ approve break-glass requests, and revoke (a live kill-switch) in the dashboard
4038
+ → Agent Identities. Unknown agents are auto-discovered there for visibility.
4039
+
4040
+ ${bold('LLM-PROXY')}
4041
+ Runs a local guard in front of your LLM providers. Point your SDK's base URL
4042
+ at it (OPENAI_BASE_URL / ANTHROPIC_BASE_URL, the Google GenAI base URL, or any
4043
+ OpenAI-compatible SDK's baseURL) — every prompt and completion is screened
4044
+ against your org's policies; violations are blocked with HTTP 403 and logged
4045
+ to the LLM Guard dashboard. Supported providers:
4046
+ ${dim(LLM_PROVIDERS.join(' · '))}
4047
+ openai + the OpenAI-compatible ones share the /<provider>/v1 path shape;
4048
+ anthropic and gemini use their own (/anthropic, /gemini).
4049
+
4050
+ ${bold('RUNTIME FIREWALL (multi-agent)')}
4051
+ ${bold('shomra install-hook')} wires Shomra into a coding agent's own hook system so
4052
+ it screens both channels — the pre-tool-call hook BEFORE a shell command,
4053
+ artifact write (adding an MCP/skill/command/hook/rules file), or MCP call
4054
+ runs, and the post-tool-call hook that screens content (WebFetch/Read/MCP
4055
+ responses) coming BACK into the agent's context for prompt injection, exfil
4056
+ sinks, and hidden payloads before the model acts on them.
4057
+
4058
+ Default target is Claude Code (unchanged for existing installs). Add
4059
+ ${bold('--agent <name>')} (comma-separated, or ${bold('all')}) to also wire in:
4060
+ ${dim('claude')} (Claude Code) · ${dim('cursor')} (Cursor) · ${dim('windsurf')} (Windsurf/Cascade) ·
4061
+ ${dim('gemini')} (Gemini CLI) · ${dim('codex')} (OpenAI Codex CLI) · ${dim('copilot')} (GitHub Copilot CLI)
4062
+ e.g. ${dim('shomra install-hook --agent cursor,windsurf')} or ${dim('shomra install-hook --agent all')}.
4063
+ Windsurf's post-hooks can flag/log but not withhold a result (vendor limit).
4064
+
4065
+ Risky calls/results are blocked and every decision lands in Gate Activity,
4066
+ tagged with which agent triggered it.
4067
+
4068
+ ${bold('Tiered enforcement (fast + unbreakable).')} The guard decides the dangerous
4069
+ majority ON-MACHINE with zero network — curl|sh, reverse shells, base64 RCE,
4070
+ live secrets, injection — so protection survives a slow, down, or blocked
4071
+ backend and adds no latency to ordinary calls. Only policy-relevant calls
4072
+ (artifact installs, MCP calls, agent-identity, network egress, or anything
4073
+ the local tier flags) escalate to the server for the full org-policy /
4074
+ identity / governance / flow engine, with a short timeout + a circuit breaker
4075
+ that skips a known-down backend. Fail-open by default (the local tier is still
4076
+ enforcing); SHOMRA_GUARD_STRICT=1 to also fail-closed on the server tier.
4077
+
4078
+ ${bold('ENV')}
4079
+ SHOMRA_API_KEY API key (overrides config)
4080
+ SHOMRA_URL Backend URL (overrides config)
4081
+ SHOMRA_API_TIMEOUT_MS=30000 Per-request backend timeout for scan/gate/report (never hangs)
4082
+ SHOMRA_AGENT Agent-identity handle presented as x-shomra-agent (llm-proxy + firewall)
4083
+ SHOMRA_GUARD_STRICT=1 Fail-closed on the server tier if the backend is unreachable
4084
+ SHOMRA_GUARD_LOCAL=0 Disable the on-machine Tier-0 guard (route everything to the server)
4085
+ SHOMRA_GUARD_IGNORE=<globs> Comma-separated file globs the runtime guard treats as known-safe (never
4086
+ withheld) — plus any .shomraignore in the working dir. For files with
4087
+ benign patterns in source (detection code, fixtures, docs).
4088
+ SHOMRA_GUARD_ALWAYS_ESCALATE=1 Send every call to the server (full telemetry, higher overhead)
4089
+ SHOMRA_GUARD_TIMEOUT_MS=2000 Per-call server timeout budget (default 2000)
4090
+ SHOMRA_GUARD_BREAKER_MS=30000 Skip the server for this long after a failure (0 disables)
4091
+ `);
4092
+ }
4093
+
4094
+ // The full verb table — every handler normalized to a (flags, positional) thunk
4095
+ // so both the top-level dispatcher and the `admin` namespace share one source of
4096
+ // truth. Adding a verb here wires it into both automatically.
4097
+ const COMMANDS = {
4098
+ init: (f) => cmdInit(f),
4099
+ scan: (f) => cmdScan(f),
4100
+ report: (f) => cmdScan({ ...f, report: true }),
4101
+ gate: (f, p) => cmdGate(f, p),
4102
+ check: (f, p) => cmdCheck(f, p),
4103
+ pr: (f, p) => cmdPr(f, p),
4104
+ baseline: (f, p) => cmdBaseline(f, p),
4105
+ fix: (f, p) => cmdFix(f, p),
4106
+ why: (f, p) => cmdWhy(f, p),
4107
+ 'install-precommit': (f, p) => cmdInstallPrecommit(f, p),
4108
+ 'scan-zip': (f, p) => cmdScanZip(f, p),
4109
+ 'model-scan': (f, p) => cmdModelScan(f, p),
4110
+ models: (f, p) => cmdModels(f, p),
4111
+ 'memory-scan': (f, p) => cmdMemoryScan(f, p),
4112
+ redteam: (f) => cmdRedteam(f),
4113
+ campaign: (f) => cmdCampaign(f),
4114
+ harden: (f) => cmdHarden(f),
4115
+ 'agent-identity': (f, p) => cmdAgentIdentity(f, p),
4116
+ 'agent-id': (f, p) => cmdAgentIdentity(f, p),
4117
+ 'llm-proxy': (f) => cmdLlmProxy(f),
4118
+ 'tool-guard': (f) => cmdToolGuard(f),
4119
+ 'result-guard': (f) => cmdResultGuard(f),
4120
+ 'install-hook': (f) => cmdInstallHook(f),
4121
+ protect: (f) => cmdProtect(f),
4122
+ doctor: (f) => cmdDoctor(f),
4123
+ new: (f, p) => cmdNew(f, p),
4124
+ mcp: (f, p) => cmdMcp(f, p),
4125
+ secrets: (f, p) => cmdSecrets(f, p),
4126
+ status: () => cmdStatus(),
4127
+ };
4128
+
4129
+ // Governance / advanced verbs. They keep working at the top level (back-compat),
4130
+ // but the help leads with the daily verbs and points here for the rest, so the
4131
+ // front door reads as a handful of commands rather than thirty. `shomra admin`
4132
+ // (no subcommand) lists them.
4133
+ const ADMIN_VERBS = new Set([
4134
+ 'scan-zip', 'model-scan', 'memory-scan',
4135
+ 'redteam', 'campaign', 'harden',
4136
+ 'agent-identity', 'agent-id', 'llm-proxy',
4137
+ ]);
4138
+
4139
+ async function main() {
4140
+ const [, , command, ...rest] = process.argv;
4141
+ const { flags, positional } = parseFlags(rest);
4142
+
4143
+ if (command === 'help' || command === undefined || command === '--help' || command === '-h') {
4144
+ return cmdHelp();
4145
+ }
4146
+
4147
+ // `shomra admin <verb> …` — the governance namespace.
4148
+ if (command === 'admin') {
4149
+ const sub = positional[0];
4150
+ if (!sub || sub === 'help' || flags.help) return cmdAdminHelp();
4151
+ const fn = COMMANDS[sub];
4152
+ if (!fn || !ADMIN_VERBS.has(sub)) {
4153
+ console.error(red(`Unknown admin command: ${sub ?? ''}`));
4154
+ cmdAdminHelp();
4155
+ process.exit(1);
4156
+ }
4157
+ return fn(flags, positional.slice(1));
4158
+ }
4159
+
4160
+ const fn = COMMANDS[command];
4161
+ if (!fn) {
4162
+ console.error(red(`Unknown command: ${command}`));
4163
+ cmdHelp();
4164
+ process.exit(1);
4165
+ }
4166
+ return fn(flags, positional);
4167
+ }
4168
+
4169
+ function cmdAdminHelp() {
4170
+ console.log(`
4171
+ ${bold(cyan('shomra admin'))} ${dim('— governance & advanced security operations')}
4172
+
4173
+ ${dim('Deep scans (backend + key)')}
4174
+ ${cyan('scan-zip')} Static-scan a workspace ZIP ${dim('<file.zip> [--project <id>] [--json]')}
4175
+ ${cyan('model-scan')} SAST-scan a public AI model ${dim('<hf-url | owner/model | github-url> [--project <id>] [--json]')}
4176
+ ${cyan('memory-scan')} Scan memory + rules files for poisoning ${dim('[path] [--scope …] [--writer …] [--json]')}
4177
+
4178
+ ${dim('Offense & runtime identity')}
4179
+ ${cyan('redteam')} Continuously red-team your guardrails ${dim('[--target llm-guard|model] [--evolve] [--min 80] [--fail-on-regression] [--json]')}
4180
+ ${cyan('campaign')} Autonomous multi-turn adversary run ${dim('[--objectives exfil-canary,tool-abuse] [--turns 6] [--min 80] [--json]')}
4181
+ ${cyan('harden')} Auto-fix what the red-team breached ${dim('[--run <id>] [--target llm-guard|model] [--apply] [--json]')}
4182
+ ${cyan('agent-identity')} Register a non-human agent identity ${dim('register --name "…" --type coding-agent [--json]')}
4183
+ ${cyan('llm-proxy')} Guardrail live LLM traffic ${dim('[--port 4141] [--project <id>] [--agent-id <handle>]')}
4184
+
4185
+ ${dim('Each also runs as a bare top-level verb (e.g.')} ${dim(bold('shomra redteam'))}${dim(') for back-compat.')}
4186
+ ${dim('Full details for any command:')} ${bold('shomra help')}
4187
+ `);
4188
+ }
4189
+
4190
+ main().catch((e) => {
4191
+ console.error(red('✗ ' + e.message));
4192
+ process.exit(1);
4193
+ });