@shomra/agent 0.3.14 → 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 +133 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shomra/agent",
3
- "version": "0.3.14",
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', 'save',
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([
@@ -232,8 +233,23 @@ const VALUE_FLAGS = new Set([
232
233
  'fail-on',
233
234
  // `shomra design --save --subject KIND:id` — pin the analysis to a subject.
234
235
  'subject', 'title', 'note', 'actor',
236
+ // `shomra run <playbook> --input key=value` — repeatable, see REPEATABLE_FLAGS.
237
+ 'input',
235
238
  ]);
236
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
+ }
237
253
 
238
254
  function parseFlags(argv) {
239
255
  const flags = {};
@@ -249,7 +265,7 @@ function parseFlags(argv) {
249
265
  if (eq !== -1) {
250
266
  const name = body.slice(0, eq);
251
267
  if (!KNOWN_FLAGS.has(name)) unknown.push(name);
252
- flags[name] = body.slice(eq + 1);
268
+ setFlag(flags, name, body.slice(eq + 1));
253
269
  continue;
254
270
  }
255
271
  if (!KNOWN_FLAGS.has(body)) {
@@ -263,7 +279,7 @@ function parseFlags(argv) {
263
279
  }
264
280
  const next = argv[i + 1];
265
281
  if (next !== undefined && !next.startsWith('--')) {
266
- flags[body] = next;
282
+ setFlag(flags, body, next);
267
283
  i++;
268
284
  } else flags[body] = true;
269
285
  } else positional.push(a);
@@ -2730,6 +2746,114 @@ async function cmdMemoryScan(flags, positional) {
2730
2746
  // `--min <resilience>` and/or `--fail-on-regression`. Exit: 0 = pass,
2731
2747
  // 2 = below the resilience floor or a regression appeared.
2732
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
+
2733
2857
  async function cmdRedteam(flags) {
2734
2858
  const cfg = loadConfig();
2735
2859
  const { apiKey, url } = resolveSettings(cfg);
@@ -6652,6 +6776,9 @@ ${bold('COMMANDS')}
6652
6776
  ${cyan('scan')} Discover AI tooling on this machine ${dim('[--report] [--json] [--path <dir>]')}
6653
6777
  ${cyan('report')} Discover + send inventory to your Shomra org ${dim('(alias: scan --report) [--json]')}
6654
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.')}
6655
6782
 
6656
6783
  ${dim('Setup — run once per machine / repo')}
6657
6784
  ${cyan('init')} Configure + enroll this machine ${dim('--key shm_live_… [--url <backend>]')}
@@ -6900,6 +7027,7 @@ const COMMANDS = {
6900
7027
  scan: (f) => cmdScan(f),
6901
7028
  report: (f) => cmdScan({ ...f, report: true }),
6902
7029
  gate: (f, p) => cmdGate(f, p),
7030
+ run: (f, p) => cmdRun(f, p),
6903
7031
  check: (f, p) => cmdCheck(f, p),
6904
7032
  pr: (f, p) => cmdPr(f, p),
6905
7033
  baseline: (f, p) => cmdBaseline(f, p),