@shomra/agent 0.3.13 → 0.3.15
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/package.json +1 -1
- package/shomra.mjs +191 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shomra/agent",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.15",
|
|
4
4
|
"description": "Shomra — adversarial assurance for AI agents, as a local-first CLI. Blocks dangerous tool-calls before they run, attacks your own guardrails to prove they hold, and gates AI artifacts in your editor and CI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/shomra.mjs
CHANGED
|
@@ -175,12 +175,13 @@ async function api(url, key, route, body, opts = {}) {
|
|
|
175
175
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
176
176
|
let res;
|
|
177
177
|
try {
|
|
178
|
+
const method = opts.method ?? 'POST';
|
|
178
179
|
res = await fetch(`${url}${route}`, {
|
|
179
|
-
method
|
|
180
|
+
method,
|
|
180
181
|
// Connection: close avoids undici keep-alive sockets lingering after the
|
|
181
182
|
// command finishes (which can crash process.exit on Windows).
|
|
182
183
|
headers: { 'Content-Type': 'application/json', 'X-Shomra-Key': key, Connection: 'close' },
|
|
183
|
-
body: JSON.stringify(body),
|
|
184
|
+
...(method === 'GET' ? {} : { body: JSON.stringify(body) }),
|
|
184
185
|
signal: ctrl.signal,
|
|
185
186
|
});
|
|
186
187
|
} catch (e) {
|
|
@@ -219,7 +220,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
219
220
|
'apply', 'dry-run', 'global', 'local', 'trailer', 'evolve', 'report', 'init',
|
|
220
221
|
'no-suppress', 'no-baseline', 'no-policy', 'no-index', 'adaptive',
|
|
221
222
|
'fail-on-regression', 'fail-on-blocked', 'write', 'yes', 'stdin', 'quiet', 'help',
|
|
222
|
-
'check', 'checklist', 'pre-receive', 'uninstall',
|
|
223
|
+
'check', 'checklist', 'pre-receive', 'uninstall', 'save', 'list',
|
|
223
224
|
]);
|
|
224
225
|
// Flags that take a value (`--key value` or `--key=value`).
|
|
225
226
|
const VALUE_FLAGS = new Set([
|
|
@@ -230,8 +231,25 @@ const VALUE_FLAGS = new Set([
|
|
|
230
231
|
// `--fail-on <critical|high|medium>` lets CI gate below the default
|
|
231
232
|
// (blocked-only) exit code — e.g. fail the build on a HIGH finding.
|
|
232
233
|
'fail-on',
|
|
234
|
+
// `shomra design --save --subject KIND:id` — pin the analysis to a subject.
|
|
235
|
+
'subject', 'title', 'note', 'actor',
|
|
236
|
+
// `shomra run <playbook> --input key=value` — repeatable, see REPEATABLE_FLAGS.
|
|
237
|
+
'input',
|
|
233
238
|
]);
|
|
234
239
|
const KNOWN_FLAGS = new Set([...BOOLEAN_FLAGS, ...VALUE_FLAGS]);
|
|
240
|
+
// ⚠ Flags that ACCUMULATE instead of overwriting. Everything else keeps the
|
|
241
|
+
// last value; a playbook takes several inputs, and silently dropping all but
|
|
242
|
+
// the last one would run it on defaults nobody asked for.
|
|
243
|
+
const REPEATABLE_FLAGS = new Set(['input']);
|
|
244
|
+
|
|
245
|
+
function setFlag(flags, name, value) {
|
|
246
|
+
if (!REPEATABLE_FLAGS.has(name)) {
|
|
247
|
+
flags[name] = value;
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const prev = flags[name];
|
|
251
|
+
flags[name] = prev === undefined ? value : [].concat(prev, value);
|
|
252
|
+
}
|
|
235
253
|
|
|
236
254
|
function parseFlags(argv) {
|
|
237
255
|
const flags = {};
|
|
@@ -247,7 +265,7 @@ function parseFlags(argv) {
|
|
|
247
265
|
if (eq !== -1) {
|
|
248
266
|
const name = body.slice(0, eq);
|
|
249
267
|
if (!KNOWN_FLAGS.has(name)) unknown.push(name);
|
|
250
|
-
flags
|
|
268
|
+
setFlag(flags, name, body.slice(eq + 1));
|
|
251
269
|
continue;
|
|
252
270
|
}
|
|
253
271
|
if (!KNOWN_FLAGS.has(body)) {
|
|
@@ -261,7 +279,7 @@ function parseFlags(argv) {
|
|
|
261
279
|
}
|
|
262
280
|
const next = argv[i + 1];
|
|
263
281
|
if (next !== undefined && !next.startsWith('--')) {
|
|
264
|
-
flags
|
|
282
|
+
setFlag(flags, body, next);
|
|
265
283
|
i++;
|
|
266
284
|
} else flags[body] = true;
|
|
267
285
|
} else positional.push(a);
|
|
@@ -2728,6 +2746,114 @@ async function cmdMemoryScan(flags, positional) {
|
|
|
2728
2746
|
// `--min <resilience>` and/or `--fail-on-regression`. Exit: 0 = pass,
|
|
2729
2747
|
// 2 = below the resilience floor or a regression appeared.
|
|
2730
2748
|
|
|
2749
|
+
// ── playbooks: the whole assurance loop as ONE command ────────────────────
|
|
2750
|
+
//
|
|
2751
|
+
// `pre-release` and `agent-release-check` were written to be run by a pipeline
|
|
2752
|
+
// and had no client — the backend has carried an API-key controller for exactly
|
|
2753
|
+
// this since the engine shipped. Exit code is the product: a failed `gate` step
|
|
2754
|
+
// comes back FAILED, and that is the assertion holding, not a broken run.
|
|
2755
|
+
|
|
2756
|
+
const STEP_MARK = { SUCCESS: () => green('✓'), FAILED: () => red('✗'), SKIPPED: () => dim('–'), RUNNING: () => cyan('•'), PENDING: () => dim('·') };
|
|
2757
|
+
|
|
2758
|
+
async function cmdRun(flags, positional) {
|
|
2759
|
+
const id = positional[0];
|
|
2760
|
+
|
|
2761
|
+
/**
|
|
2762
|
+
* ⚠ USAGE BEFORE ENROLMENT. A malformed `--input` is a typo the caller can
|
|
2763
|
+
* fix without a key, and answering it with "not configured" sends them to
|
|
2764
|
+
* the wrong problem.
|
|
2765
|
+
*
|
|
2766
|
+
* --input k=v, repeatable. Numbers and booleans keep their type, because a
|
|
2767
|
+
* threshold arriving as "80" is not the same argument.
|
|
2768
|
+
*/
|
|
2769
|
+
const inputs = {};
|
|
2770
|
+
for (const raw of [].concat(flags.input ?? [])) {
|
|
2771
|
+
const eq = String(raw).indexOf('=');
|
|
2772
|
+
if (eq < 0) {
|
|
2773
|
+
console.error(` ${red('✗')} --input expects key=value (got ${JSON.stringify(raw)})\n`);
|
|
2774
|
+
process.exit(EXIT_USAGE);
|
|
2775
|
+
}
|
|
2776
|
+
const k = String(raw).slice(0, eq).trim();
|
|
2777
|
+
const v = String(raw).slice(eq + 1);
|
|
2778
|
+
inputs[k] = v === 'true' ? true : v === 'false' ? false : /^-?\d+(\.\d+)?$/.test(v) ? Number(v) : v;
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
const cfg = loadConfig();
|
|
2782
|
+
const { apiKey, url } = resolveSettings(cfg);
|
|
2783
|
+
if (!apiKey) exitNotConfigured();
|
|
2784
|
+
|
|
2785
|
+
if (!id || flags.list) {
|
|
2786
|
+
let catalog;
|
|
2787
|
+
try {
|
|
2788
|
+
catalog = await api(url, apiKey, '/playbooks/agent/catalog', null, { method: 'GET' });
|
|
2789
|
+
} catch (e) {
|
|
2790
|
+
console.error(` ${red('✗')} ${e.message}\n`);
|
|
2791
|
+
process.exit(1);
|
|
2792
|
+
}
|
|
2793
|
+
if (flags.json) {
|
|
2794
|
+
console.log(JSON.stringify(catalog, null, 2));
|
|
2795
|
+
return;
|
|
2796
|
+
}
|
|
2797
|
+
console.log(`\n ${bold('Playbooks')} ${dim('— run one end-to-end, exit non-zero when a gate holds')}\n`);
|
|
2798
|
+
for (const p of catalog) {
|
|
2799
|
+
console.log(` ${cyan(p.id)} ${dim(`v${p.version} · ${p.steps.length} steps`)}`);
|
|
2800
|
+
console.log(` ${p.name}`);
|
|
2801
|
+
const required = Object.entries(p.inputs || {}).filter(([, d]) => d.required).map(([k]) => k);
|
|
2802
|
+
if (required.length) console.log(` ${dim('needs')} ${required.map((k) => bold(k)).join(', ')}`);
|
|
2803
|
+
console.log('');
|
|
2804
|
+
}
|
|
2805
|
+
console.log(dim(` Run one: ${bold('shomra run <id> --input key=value')}\n`));
|
|
2806
|
+
if (!id) process.exitCode = EXIT_USAGE;
|
|
2807
|
+
return;
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
if (!flags.json) process.stdout.write(dim(`\n Running ${bold(id)}… `));
|
|
2811
|
+
let run;
|
|
2812
|
+
try {
|
|
2813
|
+
run = await api(url, apiKey, `/playbooks/agent/${encodeURIComponent(id)}/run`, {
|
|
2814
|
+
inputs,
|
|
2815
|
+
...(flags.project ? { projectId: String(flags.project) } : {}),
|
|
2816
|
+
actor: `${os.hostname()}/${os.userInfo().username}`,
|
|
2817
|
+
});
|
|
2818
|
+
} catch (e) {
|
|
2819
|
+
if (!flags.json) console.log(red('failed'));
|
|
2820
|
+
console.error(` ${red('✗')} ${e.message}\n`);
|
|
2821
|
+
process.exit(1);
|
|
2822
|
+
}
|
|
2823
|
+
if (!flags.json) console.log(green('done'));
|
|
2824
|
+
|
|
2825
|
+
if (flags.json) {
|
|
2826
|
+
console.log(JSON.stringify(run, null, 2));
|
|
2827
|
+
} else {
|
|
2828
|
+
console.log(`\n ${bold(run.playbookName)} ${dim(`· ${run.playbookId}`)}\n`);
|
|
2829
|
+
for (const s of run.steps || []) {
|
|
2830
|
+
const mark = (STEP_MARK[s.status] || STEP_MARK.PENDING)();
|
|
2831
|
+
// ⚠ SKIPPED is stated as its own thing. A step whose condition was false
|
|
2832
|
+
// did not run, and printing it as a pass claims work nothing did.
|
|
2833
|
+
const note =
|
|
2834
|
+
s.status === 'SKIPPED'
|
|
2835
|
+
? dim('skipped — its condition was false')
|
|
2836
|
+
: s.error
|
|
2837
|
+
? (s.uses === 'gate' ? yellow(s.error) : red(s.error))
|
|
2838
|
+
: '';
|
|
2839
|
+
console.log(` ${mark} ${s.name}${note ? ' ' + dim('·') + ' ' + note : ''}`);
|
|
2840
|
+
}
|
|
2841
|
+
const failedStep = (run.steps || []).find((s) => s.status === 'FAILED');
|
|
2842
|
+
const gateHeld = run.status === 'FAILED' && failedStep?.uses === 'gate';
|
|
2843
|
+
console.log('');
|
|
2844
|
+
if (run.status === 'FAILED' && gateHeld) {
|
|
2845
|
+
console.log(` ${yellow('⚠')} ${bold('The gate held.')} ${dim('That is the assertion firing — exiting non-zero.')}\n`);
|
|
2846
|
+
} else if (run.status === 'FAILED') {
|
|
2847
|
+
console.log(` ${red('✗')} ${bold('A step failed.')} ${dim(run.error || '')}\n`);
|
|
2848
|
+
} else {
|
|
2849
|
+
console.log(` ${green('✓')} ${bold('Completed.')} ${dim('Every step that was meant to run did.')}\n`);
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
// The exit code IS the feature: a pipeline gates on this.
|
|
2854
|
+
if (run.status === 'FAILED') process.exitCode = 1;
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2731
2857
|
async function cmdRedteam(flags) {
|
|
2732
2858
|
const cfg = loadConfig();
|
|
2733
2859
|
const { apiKey, url } = resolveSettings(cfg);
|
|
@@ -5067,7 +5193,7 @@ async function addPackage(flags, positional) {
|
|
|
5067
5193
|
|
|
5068
5194
|
// ── shomra design: threat-model a system before it exists ───────────────────
|
|
5069
5195
|
//
|
|
5070
|
-
// shomra design <file|dir|-> [--checklist] [--json] [--strict]
|
|
5196
|
+
// shomra design <file|dir|-> [--save --subject KIND:id] [--checklist] [--json] [--strict]
|
|
5071
5197
|
//
|
|
5072
5198
|
// The leftmost surface Shomra has. Everything else needs an artifact; this reads
|
|
5073
5199
|
// a DESCRIPTION — an RFC, a design doc, a Jira/Linear ticket, a PR body — and
|
|
@@ -5136,6 +5262,11 @@ async function cmdDesign(flags, positional) {
|
|
|
5136
5262
|
}
|
|
5137
5263
|
}
|
|
5138
5264
|
|
|
5265
|
+
// --save pins the analysis to the live capability manifest server-side, so the
|
|
5266
|
+
// dashboard and the CI gate can tell later that the system has moved past it.
|
|
5267
|
+
// Without it the analysis dies with the terminal it printed to.
|
|
5268
|
+
if (flags.save) await saveDesign(results, flags);
|
|
5269
|
+
|
|
5139
5270
|
// CRITICAL = untrusted input reaching execution or a destructive action. That
|
|
5140
5271
|
// is a hard fail even without --strict: it is the one shape where the attacker
|
|
5141
5272
|
// picks the action, and no amount of care in the implementation recovers it.
|
|
@@ -5143,6 +5274,55 @@ async function cmdDesign(flags, positional) {
|
|
|
5143
5274
|
else if (open.length && flags.strict) process.exitCode = 2;
|
|
5144
5275
|
}
|
|
5145
5276
|
|
|
5277
|
+
// ── shomra design --save --subject KIND:id ──────────────────────────────────
|
|
5278
|
+
//
|
|
5279
|
+
// ⚠ ONE SUBJECT, ONE DOCUMENT. A directory of RFCs produces one analysis each,
|
|
5280
|
+
// and pinning several of them to the same subject would leave the subject
|
|
5281
|
+
// described by whichever happened to be written last. The command refuses
|
|
5282
|
+
// rather than picking.
|
|
5283
|
+
async function saveDesign(results, flags) {
|
|
5284
|
+
const { apiKey, url } = resolveSettings(loadConfig());
|
|
5285
|
+
const subject = String(flags.subject ?? '').trim();
|
|
5286
|
+
const m = /^(AGENT|PROJECT|DESIGN):(.+)$/i.exec(subject);
|
|
5287
|
+
if (!m) {
|
|
5288
|
+
console.error(red('✗') + ' --save needs ' + bold('--subject KIND:id') + dim(' (AGENT | PROJECT | DESIGN)'));
|
|
5289
|
+
console.error(dim(' e.g. ') + 'shomra design docs/rfc.md --save --subject DESIGN:inbox-agent');
|
|
5290
|
+
process.exit(EXIT_USAGE);
|
|
5291
|
+
}
|
|
5292
|
+
if (results.length !== 1) {
|
|
5293
|
+
console.error(red('✗') + ` --save takes one document; ${results.length} were modelled.`);
|
|
5294
|
+
console.error(dim(' Save each against its own subject.'));
|
|
5295
|
+
process.exit(EXIT_USAGE);
|
|
5296
|
+
}
|
|
5297
|
+
if (!apiKey || !url) exitNotConfigured();
|
|
5298
|
+
|
|
5299
|
+
const [, kind, id] = m;
|
|
5300
|
+
const r = results[0];
|
|
5301
|
+
process.stdout.write(dim(' Saving to the platform… '));
|
|
5302
|
+
try {
|
|
5303
|
+
const res = await api(url, apiKey, '/threat-models/agent-author', {
|
|
5304
|
+
subjectKind: kind.toUpperCase(),
|
|
5305
|
+
subjectId: id,
|
|
5306
|
+
title: flags.title ? String(flags.title) : r.name,
|
|
5307
|
+
analysis: r,
|
|
5308
|
+
note: flags.note ? String(flags.note) : `Modelled from ${r.name}.`,
|
|
5309
|
+
actor: flags.actor ? String(flags.actor) : undefined,
|
|
5310
|
+
});
|
|
5311
|
+
const v = res?.version;
|
|
5312
|
+
const c = res?.coverage;
|
|
5313
|
+
console.log(green('saved') + dim(` — v${v?.seq ?? '?'}, ${v?.reviewState ?? 'IN_REVIEW'}`));
|
|
5314
|
+
// ⚠ Saving is not approving. A version nobody else has signed off does not
|
|
5315
|
+
// clear the gate, and saying so here is the difference between a stored
|
|
5316
|
+
// document and a control.
|
|
5317
|
+
console.log(dim(' It is not approved yet — a threat model must be reviewed by someone other than its author.'));
|
|
5318
|
+
if (c?.headline) console.log(dim(' ') + c.headline);
|
|
5319
|
+
} catch (e) {
|
|
5320
|
+
console.log(red('failed'));
|
|
5321
|
+
console.error(dim(' ') + (e?.message || String(e)));
|
|
5322
|
+
process.exitCode = 1;
|
|
5323
|
+
}
|
|
5324
|
+
}
|
|
5325
|
+
|
|
5146
5326
|
const DESIGN_DOC_RE = /\.(md|markdown|txt|rst|adoc)$/i;
|
|
5147
5327
|
const DESIGN_MAX_DOCS = 50;
|
|
5148
5328
|
|
|
@@ -6596,6 +6776,9 @@ ${bold('COMMANDS')}
|
|
|
6596
6776
|
${cyan('scan')} Discover AI tooling on this machine ${dim('[--report] [--json] [--path <dir>]')}
|
|
6597
6777
|
${cyan('report')} Discover + send inventory to your Shomra org ${dim('(alias: scan --report) [--json]')}
|
|
6598
6778
|
${cyan('status')} Show config, enrollment + firewall health
|
|
6779
|
+
${cyan('run')} ${bold('Run a whole assurance playbook')} ${dim('<id> [--input k=v]… [--project <id>] [--json] · --list for the catalog')}
|
|
6780
|
+
${dim('scan → red-team → harden → compliance → gate, as one command. Exits')}
|
|
6781
|
+
${dim('non-zero when a gate holds, so a pipeline can block the release.')}
|
|
6599
6782
|
|
|
6600
6783
|
${dim('Setup — run once per machine / repo')}
|
|
6601
6784
|
${cyan('init')} Configure + enroll this machine ${dim('--key shm_live_… [--url <backend>]')}
|
|
@@ -6606,7 +6789,7 @@ ${bold('COMMANDS')}
|
|
|
6606
6789
|
${cyan('doctor')} ${bold('Am I safe?')} Posture of this machine's AI setup ${dim('[--json]')}
|
|
6607
6790
|
|
|
6608
6791
|
${dim('Prevention — get in front of the model, not just behind it')}
|
|
6609
|
-
${cyan('design')} ${bold('Threat-model a system before it exists')} ${dim('<file|dir|-> [--checklist] [--strict] [--json]')}
|
|
6792
|
+
${cyan('design')} ${bold('Threat-model a system before it exists')} ${dim('<file|dir|-> [--save --subject KIND:id] [--checklist] [--strict] [--json]')}
|
|
6610
6793
|
${dim('Reads an RFC / design doc / ticket and says whether it closes a path from')}
|
|
6611
6794
|
${dim('untrusted input to a consequence, plus what must be true before it ships.')}
|
|
6612
6795
|
${dim('Pipe a ticket straight in: ')}${bold('gh issue view 42 --json body -q .body | shomra design -')}
|
|
@@ -6844,6 +7027,7 @@ const COMMANDS = {
|
|
|
6844
7027
|
scan: (f) => cmdScan(f),
|
|
6845
7028
|
report: (f) => cmdScan({ ...f, report: true }),
|
|
6846
7029
|
gate: (f, p) => cmdGate(f, p),
|
|
7030
|
+
run: (f, p) => cmdRun(f, p),
|
|
6847
7031
|
check: (f, p) => cmdCheck(f, p),
|
|
6848
7032
|
pr: (f, p) => cmdPr(f, p),
|
|
6849
7033
|
baseline: (f, p) => cmdBaseline(f, p),
|