@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/shomra.mjs +191 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shomra/agent",
3
- "version": "0.3.13",
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: 'POST',
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[name] = body.slice(eq + 1);
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[body] = next;
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),