@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/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 method calls that merely end
77
- // in those letters (`db.exec(`, `RE.exec(`, `page.$eval(`, `$pdo->exec(`).
78
- { name: 'Inline eval / exec of a string', re: /(?<![.\w$>:])(eval|exec)\s*[("`']/i, severity: 'HIGH' },
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
- { re: /\b(base64|hex|rot13|gzip|xor|url[- ]?encod)\w*\b[^.\n]{0,50}\b(before|then|and|prior to|for)\b[^.\n]{0,25}\b(send|post|upload|transmit|exfil|deliver|beacon|forward|transfer)\w*/i, label: 'obfuscate-before-send', severity: 'CRITICAL' },
728
- { 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' },
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
- if (hasImperative && containsWord(text, SENSITIVE_READ) && containsWord(text, NETWORK_VERBS)) {
793
- 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.');
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.1",
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"