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