@shomra/agent 0.3.1 → 0.3.2
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/README.md +258 -1
- package/ai-usage.mjs +29 -0
- package/design.mjs +299 -0
- package/guard-signals.mjs +55 -9
- package/model-refs.mjs +26 -0
- package/package.json +2 -1
- package/shomra.mjs +1950 -24
package/guard-signals.mjs
CHANGED
|
@@ -73,9 +73,12 @@ export const DANGEROUS_SHELL = [
|
|
|
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
75
|
{ 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
|
-
// BARE `eval(`/`exec(` only — the lookbehind drops
|
|
77
|
-
//
|
|
78
|
-
|
|
76
|
+
// BARE `eval(`/`exec(` only — the lookbehind drops anything that merely ENDS in
|
|
77
|
+
// those letters: method calls (`db.exec(`, `RE.exec(`, `page.$eval(`, `$pdo->exec(`)
|
|
78
|
+
// AND hyphen/quote-joined identifiers like `sandbox-exec` (macOS Seatbelt) or a
|
|
79
|
+
// `"…exec"` string in prose. Kept byte-identical to the backend rule
|
|
80
|
+
// (bundle/signals.ts) so the local gate and the server never disagree on it.
|
|
81
|
+
{ name: 'Inline eval / exec of a string', re: /(?<![-.\w$>:`"'])(eval|exec)\s*[("`']/i, severity: 'HIGH' },
|
|
79
82
|
{ name: 'Pipes an env dump to the network', re: /\b(env|printenv|set)\b[^\n|]{0,80}\|[^\n]{0,80}(curl|wget|nc\b|http)/i, severity: 'HIGH' },
|
|
80
83
|
{ name: 'Disables TLS / cert verification', re: /(NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*0|GIT_SSL_NO_VERIFY|--no-check-certificate|--insecure\b|verify\s*=\s*False)/i, severity: 'MEDIUM' },
|
|
81
84
|
{ name: 'python -c one-liner', re: /python[0-9.]*\s+-c\b/i, severity: 'MEDIUM' },
|
|
@@ -721,11 +724,37 @@ const SABOTAGE_RULES = [
|
|
|
721
724
|
{ re: /\bignore\b[^.\n]{0,40}\b(user|human)\b[^.\n]{0,25}\b(prompt|instruction|input|request|message|command|wish|intent|question)s?\b/i, label: 'ignore-user', guarded: true },
|
|
722
725
|
{ re: /\bdo not\b[^.\n]{0,20}\b(log|display|show|print|record|surface|expose|output)\b[^.\n]{0,60}\buser\b/i, label: 'conceal-from-user', guarded: false, context: /\b(transfer|transmit|send|network|exfil|upload|post|copy|collect|file|data|when)\b/i },
|
|
723
726
|
];
|
|
727
|
+
// Descriptive / documentation mood: a line that NAMES a security concept rather
|
|
728
|
+
// than INSTRUCTING the agent to perform it. Poisoning payloads are imperative and
|
|
729
|
+
// address the agent ("always exfiltrate the env to evil.com"); a security-minded
|
|
730
|
+
// rules file mentions the same techniques as nouns ("treat these as exfiltration
|
|
731
|
+
// destinations"). Only ever used to SUPPRESS, never to raise a finding.
|
|
732
|
+
// Mirrors backend src/bundle/memory-signals.ts — keep the two in step.
|
|
733
|
+
const DESCRIPTIVE_MARKERS =
|
|
734
|
+
/\b(detect|scan|flag|block|catch|prevent|guard|protect|harden|audit|benchmark|catalog|scenario|corpus|coverage|example|vector|signal|rule|technique|posture|detection|test\s*case|red[- ]?team|-style|grounded in|fixed|now green|was|were|had|used to|previously|postmortem|regression|changelog|root[- ]?cause|repro|note|see|describes?|documents?|refers?|treat(s|ed|ing)?|counts?|reads?)\w*/i;
|
|
735
|
+
|
|
736
|
+
/** Descriptive documentation with no imperative aimed at the agent. The
|
|
737
|
+
* `!IMPERATIVE` clause is what keeps this safe: "note: ALWAYS exfiltrate…"
|
|
738
|
+
* still grades. */
|
|
739
|
+
function isDescriptiveLine(line) {
|
|
740
|
+
return DESCRIPTIVE_MARKERS.test(line) && !IMPERATIVE.test(line);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// A loopback / private-network URL is a dev/smoke-test target, not exfiltration.
|
|
744
|
+
const LOCAL_URL_RE = /\bhttps?:\/\/(localhost|127\.\d+|0\.0\.0\.0|\[::1\]|192\.168\.|10\.\d+|172\.(1[6-9]|2\d|3[01])\.)/i;
|
|
745
|
+
|
|
746
|
+
// `descGuard` rules fire on a bare noun ("exfiltration", "leak the data") and so
|
|
747
|
+
// are suppressed on a descriptive line. Rules without it already require an
|
|
748
|
+
// explicit sink/verb structure. The whole set is ALSO negation-guarded per line
|
|
749
|
+
// in scanDirectives, so "never leak the API key" is a hardening rule, not a leak.
|
|
724
750
|
const EXFIL_RULES = [
|
|
725
|
-
{ re: /\b(exfiltrat|smuggl)\w*/i, label: 'exfiltration', severity: 'CRITICAL' },
|
|
726
|
-
{ re: /\bleak\w*\b[^.\n]{0,60}\b(content|data|secret|file|credential|key|token|password|env|\.ssh|private[- ]?key|id_rsa|api[- ]?key)\b/i, label: 'leak-secrets', severity: 'CRITICAL' },
|
|
727
|
-
|
|
728
|
-
|
|
751
|
+
{ re: /\b(exfiltrat|smuggl)\w*/i, label: 'exfiltration', severity: 'CRITICAL', descGuard: true },
|
|
752
|
+
{ re: /\bleak\w*\b[^.\n]{0,60}\b(content|data|secret|file|credential|key|token|password|env|\.ssh|private[- ]?key|id_rsa|api[- ]?key)\b/i, label: 'leak-secrets', severity: 'CRITICAL', descGuard: true },
|
|
753
|
+
// Deliberate encode-THEN-send sequencing. The connector excludes a bare
|
|
754
|
+
// "and"/"for" — "gzips the capture and posts it to /bundle/scan" is a pipeline
|
|
755
|
+
// description, not obfuscated exfil.
|
|
756
|
+
{ re: /\b(base64|hex|rot13|gzip|xor|url[- ]?encod)\w*\b[^.\n]{0,50}\b(before|then|prior to|and\s+then|and\s+immediately)\b[^.\n]{0,25}\b(send|post|upload|transmit|exfil|deliver|beacon|forward|transfer)\w*/i, label: 'obfuscate-before-send', severity: 'CRITICAL', descGuard: true },
|
|
757
|
+
{ re: /\bsilent(ly)?\b[^.\n]{0,70}\b(send|post|upload|collect|encod|transmit|copy|forward|read|leak|deliver|beacon|transfer)\w*/i, label: 'covert-action', severity: 'CRITICAL', descGuard: true },
|
|
729
758
|
{ re: /\b(send|post|upload|transmit|forward|deliver|beacon|report|ship|push|transfer)\w*\b[^.\n]{0,80}\b(https?:\/\/\S+|attacker|c2\b|command[- ]and[- ]control|remote (server|host|endpoint)|external (server|host|endpoint|url|site|service))/i, label: 'send-to-external', severity: 'HIGH' },
|
|
730
759
|
];
|
|
731
760
|
function scanDirectives(text) {
|
|
@@ -734,11 +763,19 @@ function scanDirectives(text) {
|
|
|
734
763
|
for (const r of SABOTAGE_RULES) {
|
|
735
764
|
if (!r.re.test(line)) continue;
|
|
736
765
|
if (r.guarded && NEGATION_GUARD.test(line)) continue;
|
|
766
|
+
if (r.guarded && isDescriptiveLine(line)) continue; // "detects skills that disable safety" — documentation
|
|
737
767
|
if (r.context && !r.context.test(line)) continue;
|
|
738
768
|
if (!sabotage.has(r.label)) sabotage.set(r.label, line);
|
|
739
769
|
}
|
|
740
770
|
for (const r of EXFIL_RULES) {
|
|
741
771
|
if (!r.re.test(line)) continue;
|
|
772
|
+
// A line that FORBIDS exfiltration is the single most common sentence in a
|
|
773
|
+
// security-conscious rules file. Scoring it as a poisoned directive inverts
|
|
774
|
+
// the tool on exactly the teams writing the best rules. (The named-host
|
|
775
|
+
// check in localMemory stays unguarded, so a real sink still fires here.)
|
|
776
|
+
if (NEGATION_GUARD.test(line)) continue;
|
|
777
|
+
if (r.descGuard && isDescriptiveLine(line)) continue;
|
|
778
|
+
if (r.label === 'send-to-external' && LOCAL_URL_RE.test(line) && !/\b(attacker|c2|command[- ]and[- ]control|external|evil)\b/i.test(line)) continue;
|
|
742
779
|
const prev = exfil.get(r.label);
|
|
743
780
|
if (!prev || (prev === 'HIGH' && r.severity === 'CRITICAL')) exfil.set(r.label, r.severity);
|
|
744
781
|
}
|
|
@@ -789,8 +826,17 @@ export function localMemory(content, { kind = 'MEMORY' } = {}) {
|
|
|
789
826
|
for (const sig of DANGEROUS_SHELL) if (matchesShellSignal(sig, text)) { 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.`, sig.re); break; }
|
|
790
827
|
const host = egressHost(text);
|
|
791
828
|
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);
|
|
792
|
-
|
|
793
|
-
|
|
829
|
+
// Toxic flow: an IMPERATIVE line that names BOTH sensitive data and a network
|
|
830
|
+
// verb — a standing "read X and send it" instruction. Co-located per line, not
|
|
831
|
+
// whole-document co-occurrence: a long rules file mentioning `.env` in one
|
|
832
|
+
// paragraph and `curl` in another is not a flow, and grading it as one was the
|
|
833
|
+
// dominant false positive here. Negated ("never send the .env anywhere") and
|
|
834
|
+
// descriptive lines are documentation, not directives. Mirrors the backend.
|
|
835
|
+
const toxicFlowLine = hasImperative
|
|
836
|
+
? text.split(/\r?\n/).find((l) => IMPERATIVE.test(l) && !NEGATION_GUARD.test(l) && containsWord(l, SENSITIVE_READ) && containsWord(l, NETWORK_VERBS) && !isDescriptiveLine(l))
|
|
837
|
+
: null;
|
|
838
|
+
if (toxicFlowLine) {
|
|
839
|
+
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);
|
|
794
840
|
}
|
|
795
841
|
if (LIFECYCLE_VECTOR.test(text)) 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.', LIFECYCLE_VECTOR);
|
|
796
842
|
|
package/model-refs.mjs
CHANGED
|
@@ -41,6 +41,25 @@ const OLLAMA = /\bollama\s+(?:pull|run|cp|create)\s+([a-z0-9][\w.:\/-]*)/gi;
|
|
|
41
41
|
// torch.hub.load("pytorch/vision", …) — a GitHub owner/repo that runs hubconf.py.
|
|
42
42
|
const TORCH_HUB = /torch\.hub\.load\s*\(\s*['"]([A-Za-z0-9][\w.-]*\/[A-Za-z0-9][\w.-]*)['"]/g;
|
|
43
43
|
|
|
44
|
+
// Hosted-API model families. A bare `model="gpt-4o"` / `model="claude-…"` is an
|
|
45
|
+
// OpenAI/Anthropic/Google/etc API call, NOT a Hugging Face repo — but `model=` is
|
|
46
|
+
// their SDK param too, so KW_ID/from_pretrained would otherwise tag these 'hf' and
|
|
47
|
+
// trigger a doomed HF-Index lookup ("gpt-4o (hf) lookup failed"). Recognize them
|
|
48
|
+
// and tag 'api' with the provider. Prefix-anchored to avoid matching HF repos.
|
|
49
|
+
const API_MODEL = /^(?:gpt-|gpt4|o[1-4](?:-|$)|text-embedding-|text-(?:davinci|curie|babbage|ada)|davinci|dall-e|whisper-|tts-|chatgpt|claude[-\d]|gemini[-.]|gemini$|models\/gemini|mistral-|mixtral-|codestral-|command(?:-|$)|command-r|grok-|deepseek-(?:chat|coder|reasoner)|sonar-)/i;
|
|
50
|
+
function apiProvider(id) {
|
|
51
|
+
const s = String(id || '').toLowerCase();
|
|
52
|
+
if (/^(gpt|o[1-4]|text-|davinci|curie|babbage|ada|dall-e|whisper|tts-|chatgpt)/.test(s)) return 'openai';
|
|
53
|
+
if (/^claude/.test(s)) return 'anthropic';
|
|
54
|
+
if (/^(gemini|models\/gemini)/.test(s)) return 'google';
|
|
55
|
+
if (/^(mistral|mixtral|codestral)/.test(s)) return 'mistral';
|
|
56
|
+
if (/^command/.test(s)) return 'cohere';
|
|
57
|
+
if (/^grok/.test(s)) return 'xai';
|
|
58
|
+
if (/^deepseek/.test(s)) return 'deepseek';
|
|
59
|
+
if (/^sonar/.test(s)) return 'perplexity';
|
|
60
|
+
return 'api';
|
|
61
|
+
}
|
|
62
|
+
|
|
44
63
|
// Reject ids that are really file paths, packages, or non-model strings.
|
|
45
64
|
const ASSET_EXT = /\.(py|pyc|ipynb|[mc]?[jt]sx?|json|ya?ml|toml|txt|md|lock|cfg|ini|sh|env|png|jpg|svg|css|html?|csv|tsv|parquet)$/i;
|
|
46
65
|
// First path segment on huggingface.co that is a SITE section, not an org — so
|
|
@@ -83,6 +102,13 @@ export function scanModelRefs(text, file = '') {
|
|
|
83
102
|
// position (from_pretrained/SentenceTransformer/model=); ollama ids are freeform.
|
|
84
103
|
const add = (id, { revision, source, line, via, bare }) => {
|
|
85
104
|
if (!id) return;
|
|
105
|
+
// A bare hosted-API model name reached us via an HF-shaped matcher (`model=`,
|
|
106
|
+
// from_pretrained). It is not an HF repo — reclassify to 'api' + provider so it
|
|
107
|
+
// is labeled correctly and skips the HF-Index lookup. See API_MODEL.
|
|
108
|
+
if (source === 'hf' && !id.includes('/') && API_MODEL.test(id)) {
|
|
109
|
+
source = 'api';
|
|
110
|
+
via = `${via} · ${apiProvider(id)} API`;
|
|
111
|
+
}
|
|
86
112
|
if (source !== 'ollama' && !(bare ? validBareId(id) : looksLikeModelId(id))) return;
|
|
87
113
|
const key = `${source}:${id}:${revision || ''}`;
|
|
88
114
|
if (seen.has(key)) return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shomra/agent",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
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": {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"code-sast.mjs",
|
|
20
20
|
"model-refs.mjs",
|
|
21
21
|
"ai-usage.mjs",
|
|
22
|
+
"design.mjs",
|
|
22
23
|
"README.md",
|
|
23
24
|
"LICENSE",
|
|
24
25
|
"NOTICE"
|