@shomra/agent 0.2.3 → 0.2.5

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/code-sast.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Local SAST rule engine for AI-artifact source code — a dependency-free port of
3
- * the platform's src/checks/code-sast.ts, so the CLI can catch the same
4
- * AI-vulnerability shapes ON-MACHINE (offline, pre-commit, in the IDE) that the
5
- * model scan and workspace scan catch server-side. Nothing here is executed.
3
+ * the server-side engine, so the CLI can catch the same AI-vulnerability shapes
4
+ * ON-MACHINE (offline, pre-commit, in the IDE) that the model scan and workspace
5
+ * scan catch server-side. Nothing here is executed.
6
6
  *
7
7
  * Three analysis tiers, worst-first, tuned for low false positives:
8
8
  *
@@ -21,11 +21,15 @@
21
21
  * • config.json / tokenizer_config.json: auto_map / custom_pipeline / declared
22
22
  * trust_remote_code → remote-code-under-load.
23
23
  *
24
- * 2. Lightweight taint tier tracks variables assigned from an LLM call
25
- * (`.generate` / `.invoke` / `.completions.create` / `.messages.create` …),
26
- * propagates that taint across simple assignments, and raises a CRITICAL
27
- * `*.llm_output_to_sink` when a tainted value reaches a code-execution sink.
28
- * This is the prompt-injection RCE shape that single-sink matching misses.
24
+ * 2. Heuristic LLM-output propagation tier (NOT true dataflow) marks a
25
+ * variable assigned from an LLM call (`.generate` / `.invoke` /
26
+ * `.completions.create` …) as tainted, propagates by NAME SUBSTRING across
27
+ * simple assignments, and raises an `*.llm_output_to_sink` finding when a
28
+ * tainted name appears in a code-execution sink's arguments. Surfaces the
29
+ * prompt-injection → RCE shape single-sink matching misses, but it is a
30
+ * low-confidence heuristic, not analysis: no scope, no reassignment/kill, no
31
+ * sanitizer awareness, no interprocedural tracking — it can over- and
32
+ * under-report. Findings are HIGH at low confidence with hedged wording.
29
33
  *
30
34
  * 3. Cross-signal chain tier — synthesises a finding when two independently
31
35
  * suspicious signals co-occur in one file: encoded-payload + code-exec
@@ -34,9 +38,9 @@
34
38
  *
35
39
  * Findings carry a stable dotted rule id, the matched SINK, an optional SOURCE, a
36
40
  * CWE, a category, a 0–1 confidence, the FILE + physical LINE and a context
37
- * SNIPPET — the exact shape the Risk Evaluation UI renders and shomra.mjs folds
38
- * into a gate result. Keep the rule bodies in sync with src/checks/code-sast.ts
39
- * drift only costs recall on the local floor; the server remains the full check.
41
+ * SNIPPET — the exact shape shomra.mjs folds into a gate result. The rule bodies
42
+ * mirror the server engine; drift only costs recall on the local floor, and the
43
+ * server remains the full check.
40
44
  */
41
45
 
42
46
  const MAX_SNIPPET = 400;
@@ -73,7 +77,7 @@ const PY_RULES = [
73
77
  // jsonpickle, torch.load, joblib/skops, numpy allow_pickle, yaml.load without a
74
78
  // safe Loader, shelve, pandas.read_pickle, mlflow.*.load_model. All run
75
79
  // __reduce__ / arbitrary code on a crafted file the moment they load.
76
- re: /\b(pickle|cpickle|dill|_pickle|cloudpickle)\.(loads?|Unpickler)\s*\(|\bjsonpickle\.(decode|loads)\s*\(|\btorch\.(load|jit\.load)\s*\(|\byaml\.(unsafe_load|load\s*\((?![^)]*Loader\s*=\s*yaml\.(Safe|Full)Loader))|\bjoblib\.load\s*\(|\bskops\.io\.load\s*\(|\bshelve\.open\s*\(|\bnumpy\.load\s*\([^)]*allow_pickle\s*=\s*True|\b(pandas|pd)\.read_pickle\s*\(|\bmlflow\.[\w.]+\.load_model\s*\(/,
80
+ re: /\b(pickle|cpickle|dill|_pickle|cloudpickle)\.(loads?|Unpickler)\s*\(|\bjsonpickle\.(decode|loads)\s*\(|\bmarshal\.loads?\s*\(|\btorch\.(load|jit\.load)\s*\(|\byaml\.(unsafe_load|load\s*\((?![^)]*Loader\s*=\s*yaml\.(Safe|Full)Loader))|\bjoblib\.load\s*\(|\bskops\.io\.load\s*\(|\bshelve\.open\s*\(|\bnumpy\.load\s*\([^)]*allow_pickle\s*=\s*True|\b(pandas|pd)\.read_pickle\s*\(|\bmlflow\.[\w.]+\.load_model\s*\(/,
77
81
  sink: (m) => m[0].replace(/\s*\($/, '').trim(),
78
82
  source: 'weight / config file',
79
83
  message: 'Deserializes data with a pickle-backed loader. A crafted file runs arbitrary code via __reduce__ on load — the primary model-hub malware vector.',
@@ -86,6 +90,9 @@ const PY_RULES = [
86
90
  severity: 'CRITICAL',
87
91
  category: 'remote-code',
88
92
  confidence: 0.95,
93
+ // codeOnly: keyword appears in docstrings / log warnings / usage examples
94
+ // (e.g. Falcon's "load without the trust_remote_code=True argument").
95
+ codeOnly: true,
89
96
  re: /trust_remote_code\s*=\s*True/,
90
97
  sink: () => 'trust_remote_code=True',
91
98
  source: 'model repository',
@@ -93,6 +100,48 @@ const PY_RULES = [
93
100
  remediation: 'Remove trust_remote_code=True. Prefer a model with native transformers support, or pin revision= to a specific reviewed commit hash and read the custom modeling code first.',
94
101
  cwe: 'CWE-94',
95
102
  },
103
+ {
104
+ id: 'python.dataset_remote_code',
105
+ title: 'Dataset loaded with trust_remote_code',
106
+ severity: 'CRITICAL',
107
+ category: 'remote-code',
108
+ confidence: 0.9,
109
+ codeOnly: true,
110
+ // datasets.load_dataset(..., trust_remote_code=True) runs the dataset's own
111
+ // Python loading script — a DISTINCT RCE vector from model trust_remote_code.
112
+ re: /\bload_dataset\s*\((?=[^)]*trust_remote_code\s*=\s*True)/,
113
+ sink: () => 'load_dataset(trust_remote_code=True)',
114
+ source: 'dataset repository',
115
+ message: 'Loads a Hugging Face dataset with trust_remote_code=True, which imports and runs the dataset\'s Python loading script in the host process — arbitrary code execution from the dataset publisher.',
116
+ remediation: 'Remove trust_remote_code=True. Use a non-script dataset format (Parquet/Arrow/CSV/JSON), or pin revision= to a reviewed commit and read the loading script first.',
117
+ cwe: 'CWE-94',
118
+ },
119
+ {
120
+ id: 'python.native_code_load',
121
+ title: 'Loads a native library (ctypes)',
122
+ severity: 'HIGH',
123
+ category: 'code-exec',
124
+ confidence: 0.85,
125
+ re: /\bctypes\.(CDLL|WinDLL|OleDLL|PyDLL|cdll|windll|oledll)\b|\bcdll\.LoadLibrary\s*\(|\bwindll\.LoadLibrary\s*\(/,
126
+ sink: (m) => m[0].replace(/\s*\($/, '').trim(),
127
+ source: 'native library',
128
+ message: 'Loads a native/shared library via ctypes. Model code has no reason to dlopen libc or an arbitrary .so/.dll — it is an execution primitive (e.g. ctypes.CDLL("libc.so.6").system(cmd)).',
129
+ remediation: 'Remove the ctypes native-library load from model/loader code. Treat any model that dlopens libraries on load as hostile.',
130
+ cwe: 'CWE-94',
131
+ },
132
+ {
133
+ id: 'python.dynamic_file_exec',
134
+ title: 'Loads and executes a Python file at runtime',
135
+ severity: 'HIGH',
136
+ category: 'code-exec',
137
+ confidence: 0.85,
138
+ re: /\bSourceFileLoader\s*\(|\bspec_from_file_location\s*\(|\bimp\.load_source\s*\(|\bexec_module\s*\(/,
139
+ sink: (m) => m[0].replace(/\s*\($/, '').trim(),
140
+ source: 'file path',
141
+ message: 'Imports and executes a Python file from a path at runtime (SourceFileLoader / spec_from_file_location / imp.load_source). This runs code that is not statically visible as an import — a common way to hide an execution path.',
142
+ remediation: 'Remove runtime file-based module loading from model code. Import only reviewed, statically-visible modules.',
143
+ cwe: 'CWE-94',
144
+ },
96
145
  {
97
146
  id: 'python.torch_remote_code',
98
147
  title: 'Loads/executes remote or native code via torch',
@@ -235,14 +284,17 @@ const PY_RULES = [
235
284
  },
236
285
  {
237
286
  id: 'python.encoded_payload',
238
- title: 'Encoded payload decode-and-run',
239
- severity: 'HIGH',
287
+ title: 'Encoded blob decode',
288
+ // LOW on its own: base64/hex decode is ubiquitous & benign in ML (tokenizer
289
+ // vocabs, quant kernels). The dangerous decode→exec case is elevated to
290
+ // CRITICAL by chain.decode_exec. marshal.loads moved to pickle_deserialization.
291
+ severity: 'LOW',
240
292
  category: 'obfuscation',
241
- confidence: 0.6,
242
- re: /\b(base64|codecs|binascii|marshal)\.(b64decode|decode|unhexlify|loads)\s*\(|bytes\.fromhex\s*\(/,
293
+ confidence: 0.5,
294
+ re: /\b(base64|codecs|binascii)\.(b64decode|decode|unhexlify)\s*\(|bytes\.fromhex\s*\(/,
243
295
  sink: (m) => m[0].replace(/\s*\($/, '').trim(),
244
- message: 'Decodes an encoded blob. Combined with eval/exec this is the "decode a base64 string then run it" packer used to smuggle payloads past a skim.',
245
- remediation: 'Decode the blob offline and inspect it. Never load model code that decodes-and-runs embedded strings.',
296
+ message: 'Decodes an encoded blob. Benign on its own (common in tokenizers/quantization), but the "decode then run it" packer combines this with eval/exec see any decode-and-run chain finding on this file.',
297
+ remediation: 'If this decode feeds eval/exec/import, decode the blob offline and inspect it. A lone decode of vocab/kernel data is expected.',
246
298
  cwe: 'CWE-506',
247
299
  },
248
300
  {
@@ -371,14 +423,17 @@ const JS_RULES = [
371
423
  const CONFIG_RULES = [
372
424
  {
373
425
  id: 'json.automodel_usage',
374
- title: 'AutoModel bound to remote code',
375
- severity: 'HIGH',
426
+ // MEDIUM, not HIGH: capability/posture fact (ships trust_remote_code code) —
427
+ // true of every legit custom model, so HIGH is alert fatigue. A real sink in
428
+ // the routed module is elevated to CRITICAL by chain.config_module_rce.
429
+ title: 'AutoModel bound to repo-shipped code (trust_remote_code)',
430
+ severity: 'MEDIUM',
376
431
  category: 'remote-code',
377
432
  confidence: 0.8,
378
433
  re: /"(AutoModel[A-Za-z]*|AutoConfig)"\s*:\s*"([^"]+)"/,
379
434
  sink: (m) => `auto_map.${m[1]}`,
380
- message: 'config.json maps an Auto* class to a class shipped in this repo. Loading the model with trust_remote_code imports and runs that code before any weights.',
381
- remediation: 'Do not use the AutoModel path for this repo. Pin revision= to a reviewed commit, or load a model with native transformers support.',
435
+ message: 'config.json maps an Auto* class to code shipped in this repo. Loading with trust_remote_code imports and runs that code before any weights — review the publisher and the referenced module. (A dangerous sink in that module is reported separately at higher severity.)',
436
+ remediation: 'Review the referenced module before loading, pin revision= to a reviewed commit, or use a model with native transformers support.',
382
437
  cwe: 'CWE-829',
383
438
  },
384
439
  {
@@ -419,10 +474,11 @@ const CONFIG_RULES = [
419
474
  },
420
475
  ];
421
476
 
422
- // ── Taint tier config: LLM output → code-execution sink ────────────
423
- // Per-language patterns for the dataflow pass. `aiCall` marks a variable tainted
424
- // when its RHS is an LLM/model call; `execSink` is the dangerous consumer. A
425
- // tainted value reaching a sink is prompt-injection → RCE.
477
+ // ── LLM-output propagation config: LLM output → code-execution sink ─────────
478
+ // Per-language patterns for the heuristic name-propagation pass (NOT dataflow).
479
+ // `aiCall` marks a variable tainted when its RHS is an LLM/model call; `execSink`
480
+ // is the dangerous consumer. A tainted name reaching a sink is the
481
+ // prompt-injection → RCE shape — reported low-confidence, to be confirmed.
426
482
  const PY_TAINT = {
427
483
  lang: 'python',
428
484
  ruleId: 'python.llm_output_to_sink',
@@ -443,13 +499,13 @@ const CHAINS = [
443
499
  title: 'Decode-and-execute packer (multi-signal)',
444
500
  severity: 'CRITICAL',
445
501
  category: 'chain',
446
- confidence: 0.8,
502
+ confidence: 0.6,
447
503
  // an encoded-blob decode AND a code-exec sink in the same file
448
504
  parts: ['python.encoded_payload', 'js.decode_and_run', 'python.dangerous_sinks', 'js.code_exec', 'python.dynamic_import'],
449
505
  needs: (ids) => (ids.has('python.encoded_payload') || ids.has('js.decode_and_run')) &&
450
506
  (ids.has('python.dangerous_sinks') || ids.has('js.code_exec') || ids.has('python.dynamic_import')),
451
507
  anchor: ['python.dangerous_sinks', 'js.code_exec', 'python.encoded_payload', 'js.decode_and_run'],
452
- message: 'This file both decodes an encoded blob and contains a code-execution sink — the two halves of a decode-then-run packer. Even split across lines, together they smuggle and execute a hidden payload.',
508
+ message: 'This file BOTH decodes an encoded blob AND contains a code-execution sink — the two halves of a decode-then-run packer. Detected by co-occurrence in one file, not a proven decode→exec path, so confirm the decoded blob is what reaches the sink; when it is, this is how a hidden payload is smuggled and run.',
453
509
  remediation: 'Decode every embedded blob offline and inspect it, and remove the decode → eval/exec path entirely. Do not ship artifacts that assemble code at runtime.',
454
510
  cwe: 'CWE-506',
455
511
  },
@@ -458,14 +514,14 @@ const CHAINS = [
458
514
  title: 'Remote-code model that also phones out',
459
515
  severity: 'CRITICAL',
460
516
  category: 'chain',
461
- confidence: 0.8,
517
+ confidence: 0.6,
462
518
  // remote-code loading AND network egress in the same file
463
519
  parts: ['python.trust_remote_code', 'python.torch_remote_code', 'json.automodel_usage', 'json.autotokenizer_usage', 'python.network_egress', 'js.network_egress'],
464
520
  needs: (ids) => (ids.has('python.trust_remote_code') || ids.has('python.torch_remote_code') ||
465
521
  ids.has('json.automodel_usage') || ids.has('json.autotokenizer_usage')) &&
466
522
  (ids.has('python.network_egress') || ids.has('js.network_egress')),
467
523
  anchor: ['python.network_egress', 'js.network_egress', 'python.trust_remote_code', 'python.torch_remote_code'],
468
- message: 'This file loads code shipped in a model repo (trust_remote_code / auto_map / torch.hub) AND opens a network connection. Remote-code modeling that also phones out is the classic staged-download / exfiltration shape.',
524
+ message: 'This file loads code shipped in a model repo (trust_remote_code / auto_map / torch.hub) AND opens a network connection. Detected by co-occurrence in one file, not a proven load→egress path. Remote-code modeling that also phones out is the classic staged-download / exfiltration shape — confirm whether the network call is reachable from the model-code load.',
469
525
  remediation: 'Do not load remote model code; use a native-transformers model or a reviewed, pinned revision. Model code should never make outbound requests — treat this artifact as hostile.',
470
526
  cwe: 'CWE-494',
471
527
  },
@@ -538,6 +594,42 @@ function escapeRe(s) {
538
594
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
539
595
  }
540
596
 
597
+ /**
598
+ * Whether byte `offset` in `text` falls inside a string literal — tracks single/
599
+ * double/backtick + triple quotes, honouring escapes. Drops `codeOnly` rule
600
+ * matches that land in a docstring / log message / usage example (the Falcon
601
+ * `trust_remote_code=True`-in-a-warning FP). Approximate and only ever more
602
+ * conservative (may skip a real hit, never invents one).
603
+ */
604
+ function isInsideString(text, offset) {
605
+ let quote = null;
606
+ let triple = false;
607
+ for (let i = 0; i < offset && i < text.length; i++) {
608
+ const c = text[i];
609
+ if (quote) {
610
+ if (c === '\\') { i++; continue; }
611
+ if (triple) {
612
+ if (c === quote && text[i + 1] === quote && text[i + 2] === quote) { i += 2; quote = null; triple = false; }
613
+ } else if (c === quote) {
614
+ quote = null;
615
+ }
616
+ continue;
617
+ }
618
+ if (c === '#') {
619
+ const nl = text.indexOf('\n', i);
620
+ if (nl === -1) return false;
621
+ i = nl;
622
+ continue;
623
+ }
624
+ if (c === '"' || c === "'" || c === '`') {
625
+ quote = c;
626
+ triple = text[i + 1] === c && text[i + 2] === c;
627
+ if (triple) i += 2;
628
+ }
629
+ }
630
+ return quote !== null;
631
+ }
632
+
541
633
  /** Map a match offset inside a logical unit back to a 0-based physical line. */
542
634
  function physicalIdx(unit, offset) {
543
635
  const newlines = unit.text.slice(0, offset).match(/\n/g);
@@ -557,10 +649,13 @@ function parseAssign(text) {
557
649
  }
558
650
 
559
651
  /**
560
- * Lightweight intra-file taint pass. Marks variables assigned from an LLM call as
561
- * tainted, propagates that across simple assignments (bounded fixed point), then
562
- * emits a CRITICAL finding wherever a tainted value flows into a code-exec sink —
563
- * the prompt-injection RCE shape a single-sink regex can't see.
652
+ * Heuristic intra-file LLM-output propagation (NOT true taint analysis). Marks
653
+ * variables assigned from an LLM call as tainted, propagates by name-substring
654
+ * across simple assignments (bounded fixed point), then emits a finding wherever
655
+ * a tainted name appears in a code-exec sink's arguments the prompt-injection →
656
+ * RCE shape a single-sink regex can't see. No scope, kill, sanitizer or
657
+ * interprocedural tracking, so it can over- and under-report; findings are HIGH
658
+ * at low confidence with hedged wording, i.e. leads to confirm, not proof.
564
659
  */
565
660
  function taintFindings(lines, units, file, cfg) {
566
661
  const tainted = new Set();
@@ -602,18 +697,19 @@ function taintFindings(lines, units, file, cfg) {
602
697
  const sink = m[0].replace(/\s*\($/, '').trim();
603
698
  out.push({
604
699
  ruleId: cfg.ruleId,
605
- title: 'LLM output reaches a code-execution sink',
606
- severity: 'CRITICAL',
607
- category: 'taint',
608
- confidence: 0.85,
700
+ title: 'LLM output may reach a code-execution sink (heuristic)',
701
+ // HIGH, not CRITICAL: name-propagation heuristic, not proven dataflow.
702
+ severity: 'HIGH',
703
+ category: 'taint-heuristic',
704
+ confidence: 0.5,
609
705
  file,
610
706
  line: idx + 1,
611
707
  sink: sink.slice(0, 120),
612
708
  source: 'LLM output',
613
- taint: `${via} (LLM output) → ${sink}`,
709
+ taint: `${via} (LLM output) → ${sink} (heuristic name match — confirm)`,
614
710
  ...contextChunk(lines, idx),
615
- message: `A value derived from an LLM call ("${via}") flows into ${sink}. A prompt-injected instruction the model emits becomes code execution in the host — the highest-severity agent vulnerability.`,
616
- remediation: 'Never pass model output to eval/exec/subprocess. Constrain the model to structured output (a fixed schema / tool-call allowlist), validate it, and dispatch on named handlers — never execute it.',
711
+ message: `A variable that appears to derive from an LLM call ("${via}") is used in ${sink}. If that value really is model-controlled, a prompt-injected instruction becomes code execution in the host — a critical agent vulnerability. Flagged by a name-propagation heuristic (no dataflow proof), so confirm the value is actually the model output and not reassigned/sanitized before this line.`,
712
+ remediation: 'If confirmed, never pass model output to eval/exec/subprocess. Constrain the model to structured output (a fixed schema / tool-call allowlist), validate it, and dispatch on named handlers — never execute it.',
617
713
  cwe: 'CWE-94',
618
714
  });
619
715
  }
@@ -673,6 +769,9 @@ function scanLines(text, file, rules, taintCfg) {
673
769
  rule.re.lastIndex = 0;
674
770
  const m = rule.re.exec(unit.text);
675
771
  if (!m) continue;
772
+ // Drop code-construct rules whose match lands inside a string literal
773
+ // (docstring / log message / usage example) — the Falcon-class FP.
774
+ if (rule.codeOnly && isInsideString(unit.text, m.index)) continue;
676
775
  const idx = physicalIdx(unit, m.index);
677
776
  const trimmed = (lines[idx] ?? '').trim();
678
777
  if (!trimmed || isCommentLine(trimmed)) continue;
package/discovery.mjs CHANGED
@@ -66,9 +66,9 @@ function firstExisting(paths) {
66
66
  }
67
67
 
68
68
  // ── workspace root discovery ─────────────────────────────────────
69
- // The old scanner only looked at cwd. Real AI assets live scattered across a
70
- // developer's project folders, so we discover those folders instead of hoping
71
- // the agent was launched from inside one.
69
+ // Real AI assets live scattered across a developer's project folders, so
70
+ // discovery walks those folders rather than assuming the CLI was launched from
71
+ // inside one.
72
72
 
73
73
  const IGNORE_DIRS = new Set([
74
74
  'node_modules', '.git', '.hg', '.svn', 'dist', 'build', 'out', '.next', '.nuxt',
package/guard-signals.mjs CHANGED
@@ -1,8 +1,7 @@
1
1
  /**
2
2
  * Tier-0 local guard signals — a dependency-free, high-confidence subset of the
3
- * backend detection engine (src/bundle/signals.ts + src/checks/patterns.ts),
4
- * ported so the runtime firewall can decide the DANGEROUS majority of tool calls
5
- * ON-BOX, with zero network round-trip.
3
+ * server-side detection engine, ported so the runtime firewall can decide the
4
+ * DANGEROUS majority of tool calls ON-BOX, with zero network round-trip.
6
5
  *
7
6
  * Why this exists: the pre-tool-call hook fires on every action. Routing every
8
7
  * call through the backend put a network dependency on the hot path — slow when
@@ -19,11 +18,50 @@
19
18
  * escalates policy-relevant calls to it; the local tier is the floor, not a
20
19
  * replacement.
21
20
  *
22
- * Keep the pattern lists roughly in sync with the server modules named above.
23
- * Drift only costs recall on the local floor — the server remains the full check.
21
+ * The pattern lists below mirror the server engine. Drift only costs recall on
22
+ * the local floor — the server remains the full check.
24
23
  */
25
24
 
26
- // ── dangerous shell (mirror of DANGEROUS_SHELL in src/bundle/signals.ts) ──
25
+ // ── precision guards ──
26
+
27
+ /** Build output every README tells you to wipe — regenerable, not real data. */
28
+ const EPHEMERAL_RM_TARGET_RE =
29
+ /^(\.\/)?(node_modules|dist|build|out|coverage|target|\.next|\.nuxt|\.turbo|\.svelte-kit|\.cache|\.parcel-cache|__pycache__|\.pytest_cache|\.mypy_cache|\.ruff_cache|\.tox|venv|\.venv|\.eggs|[\w.-]+\.egg-info)\/?\*?$/i;
30
+
31
+ /** True when an `rm -rf` line deletes something other than build output. */
32
+ function rmTargetsRealData(line) {
33
+ const m = /\brm\s+((?:-[a-zA-Z]+\s+)+)(.*)$/.exec(line);
34
+ if (!m) return true; // unparsed shape → keep the finding (fail open)
35
+ const targets = m[2]
36
+ .split(/&&|\|\||[;|>&]/)[0]
37
+ .split(/\s+/)
38
+ .filter((t) => t && !t.startsWith('-'));
39
+ if (!targets.length) return true;
40
+ return !targets.every((t) => EPHEMERAL_RM_TARGET_RE.test(t.replace(/^["']|["']$/g, '')));
41
+ }
42
+
43
+ /**
44
+ * The TEXT of the line `index` falls on — the unit a `refine` guard reasons
45
+ * about. Distinct from lineAt() (line NUMBER) and lineOf() (locate a needle).
46
+ */
47
+ function lineTextAt(text, index) {
48
+ const start = text.lastIndexOf('\n', index - 1) + 1;
49
+ const end = text.indexOf('\n', index);
50
+ return text.slice(start, end === -1 ? undefined : end);
51
+ }
52
+
53
+ /** True when `sig` fires AND at least one hit survives its precision guard. */
54
+ export function matchesShellSignal(sig, text) {
55
+ if (!sig.refine) return sig.re.test(text);
56
+ const g = new RegExp(sig.re.source, sig.re.flags.includes('g') ? sig.re.flags : sig.re.flags + 'g');
57
+ for (const m of text.matchAll(g)) {
58
+ if (m.index == null) continue;
59
+ if (sig.refine(lineTextAt(text, m.index))) return true;
60
+ }
61
+ return false;
62
+ }
63
+
64
+ // ── dangerous shell ──
27
65
  export const DANGEROUS_SHELL = [
28
66
  { name: 'Pipe-to-shell installer (curl … | sh)', re: /\b(curl|wget)\b[^\n|]{0,200}\|\s*(sudo\s+)?(ba|z|k)?sh\b/i, severity: 'CRITICAL' },
29
67
  { name: 'PowerShell download-and-run (iwr/curl … | iex)', re: /\b(iwr|curl|wget|invoke-webrequest|invoke-restmethod|irm)\b[^\n|]{0,200}\|\s*(iex|invoke-expression)\b/i, severity: 'CRITICAL' },
@@ -34,8 +72,10 @@ export const DANGEROUS_SHELL = [
34
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' },
35
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' },
36
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' },
37
- { 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' },
38
- { name: 'Inline eval / exec of a string', re: /\b(eval|exec)\s*[("`']/i, severity: 'HIGH' },
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' },
39
79
  { 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' },
40
80
  { 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' },
41
81
  { name: 'python -c one-liner', re: /python[0-9.]*\s+-c\b/i, severity: 'MEDIUM' },
@@ -43,31 +83,39 @@ export const DANGEROUS_SHELL = [
43
83
  { name: 'Netcat / socket exfil', re: /\bnc\s+-[a-z]*\b|\bncat\b/i, severity: 'MEDIUM' },
44
84
  ];
45
85
 
46
- // ── injection (mirror of INJECTION_PHRASES / INJECTION_REGEXES) ──
86
+ // ── injection ──
87
+ // Broad phrases that dominate benign dev prose ("system prompt", "run the
88
+ // following command", "do not tell the user", "never mention") are deliberately
89
+ // excluded — their real-attack shapes are caught by scanShell and the tightened
90
+ // regexes below. The injection loop also applies PRECEDING_NEGATION, so "never
91
+ // ignore previous instructions" reads as legitimate.
47
92
  export const INJECTION_PHRASES = [
48
93
  'ignore previous instructions', 'ignore all previous', 'disregard previous', 'disregard the above',
49
- 'do not tell the user', "don't tell the user", 'without informing the user', 'without telling the user',
50
- 'do not mention', "don't mention", 'never mention',
51
- 'system prompt', 'you are now', 'new instructions:', 'override your', 'exfiltrate',
52
- 'send them to', 'post them to', 'forward the',
53
- 'run this first', 'run the following command', 'run the following commands', 'copy this command',
54
- 'copy and run', 'execute the following', 'seed the current host',
94
+ 'without informing the user', 'without telling the user',
95
+ 'new instructions:', 'override your', 'exfiltrate',
96
+ 'send them to', 'post them to',
97
+ 'run this first', 'copy and run', 'seed the current host',
55
98
  'ignore all instructions', 'ignore your instructions', 'ignore your safety', 'ignore all content policies',
56
99
  'disregard your instructions', 'disregard the guidelines', 'system override', 'follow only my instructions',
57
100
  'do anything now', 'reveal any credential',
58
101
  'save this to your memory', 'in all future sessions', 'remember this forever',
59
102
  ];
60
103
  export const INJECTION_REGEXES = [
61
- { label: 'Instruction-override phrasing', re: /\b(ignore|disregard|forget|discard|override|bypass|skip)\b[\s\w,'"()-]{0,40}?\b(instruction|instructions|rule|rules|guideline|guidelines|prompt|prompts|directive|directives|context|constraint|constraints)\b/i },
62
- { label: 'Reference to overriding earlier context', re: /\b(previous|prior|above|earlier|preceding|former|the last|that (?:were |was )?given)\b[\s\w,'"()-]{0,25}?\b(instruction|instructions|rule|rules|prompt|prompts|message|messages|guidance)\b/i },
63
- { label: 'Bulk destructive command', re: /\b(delete|remove|wipe|erase|destroy|drop|purge|clear|nuke|truncate)\b[\s\w,'"()-]{0,20}?\b(all|every|each|entire|whole)\b[\s\w,'"()-]{0,15}?\b(folder|folders|file|files|directory|directories|table|tables|database|databases|record|records|repo|repos|repositor\w*|account|accounts|user|users|row|rows|document|documents|data)\b/i },
64
- { label: 'Recursive force-delete command', re: /\brm\s+-[a-z]*[rf][a-z]*\b|\brmdir\b|\bdel\s+\/[sqf]|remove-item\b[\s\S]{0,40}?-recurse/i },
65
- { label: 'Destructive SQL statement', re: /\b(drop|truncate)\s+(table|database|schema|index)\b/i },
104
+ { label: 'Instruction-override phrasing', re: /\b(ignore|disregard|override|bypass|circumvent)\b[\s\w,'"()-]{0,40}?\b(instruction|instructions|directive|directives|safety|safeguards?|guardrails?|system\s+prompt|content\s+polic\w+)\b/i },
105
+ { label: 'Instructs the agent to conceal an action from the user', re: /\b(?:do\s*n['o]?t|never|without)\s+(?:tell|telling|inform|informing|notify|notifying|alert|alerting|mention|mentioning|disclos\w+|reveal\w*)\s+(?:it\s+|this\s+|them\s+)?(?:to\s+)?(?:the\s+)?(?:user|users|human|operator|owner)\b(?!['']s)(?!\s+(?:to\b|how\s+to\b|when\s+to\b|that\s+they\b|about\b))/i },
106
+ { label: 'Bulk destructive command', re: /\b(delete|remove|wipe|erase|destroy|drop|purge|nuke|truncate)\b[\s\w,'"()-]{0,20}?\b(all|every|each|entire|whole)\b[\s\w,'"()-]{0,15}?\b(folder|folders|file|files|directory|directories|table|tables|database|databases|record|records|repo|repos|repositor\w*|account|accounts|user|users|row|rows|document|documents|data)\b/i },
107
+ { label: 'Destructive SQL statement', re: /\b(drop|truncate)\s+(table|database|schema)\b/i },
66
108
  ];
109
+ // Negation flips an override phrase into a hardening rule; a bulk-destructive hit
110
+ // on a build/test artifact is a clean step, not an attack. Applied in localScan.
111
+ const PRECEDING_NEGATION = /\b(never|not|do not|don'?t|cannot|can'?t|must not|mustn'?t|should not|shouldn'?t|avoid|refuse to|forbidden to|prohibited from|without)\s*$/i;
112
+ const BUILD_ARTIFACT = /\b(node_modules|dist|build|out|coverage|target|cache|generated|tmp|temp|__pycache__|artifacts?|logs?|tests?|test|fixtures?|staging|scratch|migrations?)\b/i;
67
113
  // zero-width / bidi / tag-block chars used to smuggle instructions (ASCII smuggling).
68
- export const INVISIBLE_CHARS_RE = /[​-‏‪-‮⁠-⁤︀-️]|[\u{E0000}-\u{E007F}]|[\u{E0100}-\u{E01EF}]/u;
114
+ // Excludes U+200D ZWJ and U+FE00–FE0F variation selectors — those render ordinary
115
+ // emoji ("⚠️", "👨‍💻") and are not a smuggling channel.
116
+ export const INVISIBLE_CHARS_RE = /[؜ᅟᅠ᠎​‌‎‏‪-‮⁠-⁤⁦-⁩ㅤᅠ-]|[\u{E0000}-\u{E007F}\u{E0100}-\u{E01EF}]/u;
69
117
 
70
- // ── secrets (mirror of SECRET_PATTERNS) ──
118
+ // ── secrets ──
71
119
  export const SECRET_PATTERNS = [
72
120
  { name: 'Stripe live key', re: /sk_live_[0-9a-zA-Z]{16,}/ },
73
121
  { name: 'OpenAI key', re: /sk-[A-Za-z0-9]{20,}/ },
@@ -84,7 +132,7 @@ export const RISKY_CONFIG_MARKERS = [
84
132
  'disable safety', 'bypass approval', 'full access', 'unrestricted',
85
133
  ];
86
134
 
87
- // ── PII (mirror of PII_PATTERNS + Luhn gate in checks/text-inspector.ts) ──
135
+ // ── PII (patterns + Luhn gate) ──
88
136
  export const PII_PATTERNS = [
89
137
  { name: 'Email address', re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/ },
90
138
  { name: 'US SSN', re: /\b\d{3}-\d{2}-\d{4}\b/ },
@@ -92,6 +140,10 @@ export const PII_PATTERNS = [
92
140
  { name: 'Phone number', re: /\b(?:\+?1[ .-]?)?\(?\d{3}\)?[ .-]?\d{3}[ .-]?\d{4}\b/ },
93
141
  { name: 'IPv4 address', re: /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/ },
94
142
  ];
143
+ // Reserved / RFC-1918 / doc / public-DNS IPs (not personal data), and a version
144
+ // context ("v1.0.0.0") that merely looks like an IP.
145
+ const RESERVED_IPV4 = /^(0\.|255\.255\.255\.255|127\.|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|192\.0\.2\.|198\.51\.100\.|203\.0\.113\.|8\.8\.(8\.8|4\.4)|1\.1\.1\.1|1\.0\.0\.1|224\.)/;
146
+ const VERSION_CONTEXT = /\b(v|ver|version|release|rev|build|semver|tag)\.?\s*$/i;
95
147
 
96
148
  // Luhn check keeps the loose credit-card regex from firing on any digit run.
97
149
  function luhnValid(value) {
@@ -123,8 +175,8 @@ export function containsAny(haystack, needles) {
123
175
  return null;
124
176
  }
125
177
 
126
- // Attacker-controlled data sinks (subset of SUSPICIOUS_EGRESS_HOSTS) a tool
127
- // call/result referencing one is an exfiltration endpoint.
178
+ // Attacker-controlled data sinks a tool call/result referencing one is an
179
+ // exfiltration endpoint.
128
180
  export const SUSPICIOUS_EGRESS_HOSTS = [
129
181
  'webhook.site', 'requestbin', 'pipedream.net', 'ngrok.io', 'ngrok-free.app', 'ngrok.app',
130
182
  'trycloudflare.com', 'serveo.net', 'localhost.run', 'interact.sh', 'oastify.com', 'oast.pro',
@@ -138,7 +190,7 @@ export const SUSPICIOUS_EGRESS_HOSTS = [
138
190
  const SEV_RANK = { INFO: 1, LOW: 2, MEDIUM: 3, HIGH: 4, CRITICAL: 5 };
139
191
 
140
192
  // Decode suspicious base64 blobs so payloads hidden in an "echo <blob>|base64 -d|sh"
141
- // trick are inspected too. We decode purely to READ the bytes; nothing runs.
193
+ // trick are inspected too. Decoding is purely to READ the bytes; nothing runs.
142
194
  const BASE64_BLOB_RE = /\b[A-Za-z0-9+/]{32,}={0,2}/g;
143
195
  const DECODED_PAYLOAD_RE = /(\/bin\/(ba|z|k)?sh|\b(ba|z|k)?sh\s+-c|\bcurl\b|\bwget\b|\beval\b|\bexec\b|https?:\/\/|invoke-expression|\biex\b|powershell|\bnc\b|\bncat\b|\bchmod\b|\bbase64\b)/i;
144
196
  function deobfuscate(text) {
@@ -305,12 +357,27 @@ export function localScan(text, opts = {}) {
305
357
  if (cats.includes('shell')) {
306
358
  const aug = deobfuscate(t);
307
359
  if (aug.decodedPayload) findings.push({ label: 'Base64-encoded shell / RCE payload', severity: 'CRITICAL', category: 'shell' });
308
- for (const sig of DANGEROUS_SHELL) if (sig.re.test(aug.text)) findings.push({ label: sig.name, severity: sig.severity, category: 'shell', ...locate(t, sig.re, mask) });
360
+ for (const sig of DANGEROUS_SHELL) if (matchesShellSignal(sig, aug.text)) findings.push({ label: sig.name, severity: sig.severity, category: 'shell', ...locate(t, sig.re, mask) });
309
361
  }
310
362
  if (cats.includes('injection')) {
311
363
  const low = t.toLowerCase();
312
- for (const p of INJECTION_PHRASES) if (low.includes(p)) { findings.push({ label: `Injected instruction: "${p}"`, severity: 'HIGH', category: 'injection', ...locate(t, p, mask) }); break; }
313
- for (const { label, re } of INJECTION_REGEXES) if (re.test(t)) findings.push({ label, severity: 'HIGH', category: 'injection', ...locate(t, re, mask) });
364
+ // First NON-NEGATED phrase (a negation right before flips it into a hardening
365
+ // rule "never ignore previous instructions").
366
+ for (const p of INJECTION_PHRASES) {
367
+ const at = low.indexOf(p);
368
+ if (at < 0) continue;
369
+ if (PRECEDING_NEGATION.test(t.slice(Math.max(0, at - 20), at))) continue;
370
+ findings.push({ label: `Injected instruction: "${p}"`, severity: 'HIGH', category: 'injection', ...locate(t, p, mask) });
371
+ break;
372
+ }
373
+ for (const { label, re } of INJECTION_REGEXES) {
374
+ const m = t.match(re);
375
+ if (!m) continue;
376
+ const at = m.index ?? 0;
377
+ if (PRECEDING_NEGATION.test(t.slice(Math.max(0, at - 20), at))) continue;
378
+ if (label === 'Bulk destructive command' && BUILD_ARTIFACT.test(m[0])) continue; // build/test cleanup
379
+ findings.push({ label, severity: 'HIGH', category: 'injection', ...locate(t, re, mask) });
380
+ }
314
381
  if (INVISIBLE_CHARS_RE.test(t)) findings.push({ label: 'Invisible / zero-width characters', severity: 'MEDIUM', category: 'injection', ...locate(t, INVISIBLE_CHARS_RE, mask) });
315
382
  }
316
383
  if (cats.includes('secret')) {
@@ -321,6 +388,14 @@ export function localScan(text, opts = {}) {
321
388
  const m = t.match(re);
322
389
  if (!m) continue;
323
390
  if (name === 'Credit card number' && !luhnValid(m[0])) continue; // gate the loose CC regex
391
+ // Infra / reserved / doc / public-DNS IPs and version strings ("v1.0.0.0")
392
+ // are not personal data.
393
+ if (name === 'IPv4 address') {
394
+ if (RESERVED_IPV4.test(m[0])) continue;
395
+ if (VERSION_CONTEXT.test(t.slice(Math.max(0, (m.index ?? 0) - 12), m.index ?? 0))) continue;
396
+ }
397
+ // A separator-less digit run is an ID / Unix timestamp, not a phone number.
398
+ if (name === 'Phone number' && /^\d+$/.test(m[0])) continue;
324
399
  findings.push({ label: `Personal data: ${name}`, severity: 'MEDIUM', category: 'pii', ...locate(t, re, mask) });
325
400
  }
326
401
  }
@@ -352,7 +427,7 @@ export function downrankCodeContext(findings) {
352
427
  }
353
428
 
354
429
  // ── local artifact gate (offline `shomra gate`) ──
355
- // Social-engineering "install-lure" prose (mirror of INSTALL_LURE server-side).
430
+ // Social-engineering "install-lure" prose.
356
431
  const INSTALL_LURE = [
357
432
  { name: 'Instructs downloading an executable/archive to run', re: /\b(download|install|fetch|grab|extract)\b[^\n]{0,180}\.(zip|exe|dmg|pkg|msi|bin|appimage|jar|scr|apk|deb|rpm|tar\.gz|tgz)\b/i, severity: 'MEDIUM' },
358
433
  { name: 'Password-protected archive (evades AV / scanners)', re: /\b(extract|unzip|decompress|archive|zip|password)\b[^\n]{0,50}\b(pass(word|phrase)?|pwd)\s*[:=]\s*\S/i, severity: 'HIGH' },
@@ -360,7 +435,7 @@ const INSTALL_LURE = [
360
435
  { name: 'Coercion: re-run / retry until it succeeds', re: /\b(re-?run (if needed|until|the command)|run (it |the command )?again|try again after)/i, severity: 'LOW' },
361
436
  ];
362
437
 
363
- // ── typosquat / malicious-package intel (mirror of checks/patterns.ts) ──
438
+ // ── typosquat / malicious-package intel ──
364
439
  const MALICIOUS_PACKAGE_SEED = new Set([
365
440
  'event-stream', 'eslint-scope-malware', 'electron-native-notify', 'rc-malware',
366
441
  'crossenv', 'mongose', 'expresss',
@@ -447,7 +522,7 @@ function frontmatter(text) {
447
522
  return data;
448
523
  }
449
524
 
450
- // ── structured MCP-config checks (mirror of ArtifactAnalyzerService.checkMcpConfig) ──
525
+ // ── structured MCP-config checks ──
451
526
  // Parses the JSON and inspects each server: plaintext HTTP (weak auth), a
452
527
  // hard-coded secret in the env block / launch line, and a typosquat / known-
453
528
  // malicious launch package — structural findings a raw-text scan can't produce.
@@ -486,7 +561,7 @@ function localMcp(content) {
486
561
  return out;
487
562
  }
488
563
 
489
- // ── structured agent-card checks (mirror of checkAgentCard endpoint analysis) ──
564
+ // ── structured agent-card checks ──
490
565
  // Grades every URL the card declares (assessUrl: metadata SSRF, private-network
491
566
  // pivot, plaintext, raw IP) and flags a public card with no auth scheme.
492
567
  function localAgentCard(content) {
@@ -518,7 +593,7 @@ function localAgentCard(content) {
518
593
  return out;
519
594
  }
520
595
 
521
- // ── slash-command extras (mirror of checkCommand: `!`-bang + `@`-file) ──
596
+ // ── slash-command extras (`!`-bang + `@`-file) ──
522
597
  function localCommandExtras(content) {
523
598
  const out = [];
524
599
  const body = content || '';
@@ -533,7 +608,7 @@ function localCommandExtras(content) {
533
608
  return out;
534
609
  }
535
610
 
536
- // ── memory / rules poisoning (mirror of bundle/memory-signals.ts analyzeMemory) ──
611
+ // ── memory / rules poisoning ──
537
612
  // A persistent memory note or an AI rules file (CLAUDE.md, .cursorrules, …) is
538
613
  // re-injected as high-authority context every session. This grades the two by a
539
614
  // different baseline: MEMORY should record facts (any standing directive is
@@ -587,7 +662,7 @@ function scanDirectives(text) {
587
662
  * 'MEMORY' (agent-writable scratchpad — any standing directive is anomalous) or
588
663
  * 'INSTRUCTION' (curated rules file — only universally-malicious signals count).
589
664
  * Returns findings shaped like localGate's ({ severity, title, remediationText,
590
- * line }). Faithful to bundle/memory-signals.ts analyzeMemory.
665
+ * line }).
591
666
  */
592
667
  export function localMemory(content, { kind = 'MEMORY' } = {}) {
593
668
  const text = content || '';
@@ -622,7 +697,7 @@ export function localMemory(content, { kind = 'MEMORY' } = {}) {
622
697
 
623
698
  // Executable payload / egress sink / lifecycle-hook references have no business
624
699
  // in a note or rules file.
625
- for (const sig of DANGEROUS_SHELL) if (sig.re.test(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; }
700
+ 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; }
626
701
  const host = egressHost(text);
627
702
  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);
628
703
  if (hasImperative && containsAny(text, SENSITIVE_READ) && containsAny(text, NETWORK_VERBS)) {
@@ -645,7 +720,7 @@ export function localMemory(content, { kind = 'MEMORY' } = {}) {
645
720
  return findings.filter((f) => (seen.has(f.title) ? false : (seen.add(f.title), true)));
646
721
  }
647
722
 
648
- // Basenames of AI rules / instruction files (mirror of INSTRUCTION_BASENAMES).
723
+ // Basenames of AI rules / instruction files.
649
724
  const INSTRUCTION_BASENAMES = new Set([
650
725
  'claude.md', 'agents.md', 'agent.md', 'gemini.md', 'llms.txt', 'llms-full.txt',
651
726
  '.cursorrules', '.windsurfrules', '.clinerules', '.aiderrules', '.continuerules',
@@ -734,8 +809,8 @@ export function localGate(content, { kind, path } = {}) {
734
809
  }
735
810
 
736
811
  // Deterministic verdict + 0–100 risk score for a set of findings, aligned with
737
- // the server default policy (any CRITICAL → BLOCK, any HIGH → FLAG) and the
738
- // SEVERITY_WEIGHT scale. Exported so callers that fold in extra findings (e.g.
812
+ // the server default policy: any CRITICAL → BLOCK, any HIGH → FLAG.
813
+ // Exported so callers that fold in extra findings (e.g.
739
814
  // the CLI merging bundled-script SAST hits) re-grade the same way.
740
815
  export function grade(findings) {
741
816
  const WEIGHT = { INFO: 2, LOW: 8, MEDIUM: 20, HIGH: 40, CRITICAL: 70 };
package/model-refs.mjs CHANGED
@@ -43,12 +43,20 @@ const TORCH_HUB = /torch\.hub\.load\s*\(\s*['"]([A-Za-z0-9][\w.-]*\/[A-Za-z0-9][
43
43
 
44
44
  // Reject ids that are really file paths, packages, or non-model strings.
45
45
  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
+ // First path segment on huggingface.co that is a SITE section, not an org — so
47
+ // huggingface.co/docs/datasets isn't mistaken for the model "docs/datasets".
48
+ const NONMODEL_ORGS = new Set([
49
+ 'docs', 'blog', 'spaces', 'datasets', 'models', 'join', 'login', 'settings',
50
+ 'pricing', 'tasks', 'learn', 'papers', 'collections', 'organizations', 'new',
51
+ 'search', 'chat', 'posts', 'enterprise', 'inference-endpoints',
52
+ ]);
46
53
  function looksLikeModelId(id) {
47
54
  if (!id || id.startsWith('@') || id.startsWith('.') || id.startsWith('/')) return false;
48
55
  if (id.includes('..') || id.split('/').length !== 2) return false;
49
56
  if (ASSET_EXT.test(id)) return false; // a model id never ends in a code/asset ext
50
57
  const [a, b] = id.split('/');
51
58
  if (!/[A-Za-z]/.test(a) || !/[A-Za-z]/.test(b)) return false; // kills "123/456"
59
+ if (NONMODEL_ORGS.has(a.toLowerCase())) return false; // site path, not an org
52
60
  return true;
53
61
  }
54
62
  // A bare id (no org) from a high-confidence position — accept unless it's clearly
package/package.json CHANGED
@@ -1,52 +1,51 @@
1
- {
2
- "name": "@shomra/agent",
3
- "version": "0.2.3",
4
- "description": "Shomra — a local-first security scanner and runtime firewall for AI agents, MCP servers, prompts, and models. Gates AI artifacts in your editor and CI.",
5
- "type": "module",
6
- "bin": {
7
- "shomra": "./shomra.mjs"
8
- },
9
- "engines": {
10
- "node": ">=18"
11
- },
12
- "scripts": {
13
- "test": "node --test \"tests/**/*.test.mjs\""
14
- },
15
- "files": [
16
- "shomra.mjs",
17
- "discovery.mjs",
18
- "guard-signals.mjs",
19
- "code-sast.mjs",
20
- "model-refs.mjs",
21
- "README.md",
22
- "LICENSE",
23
- "NOTICE"
24
- ],
25
- "keywords": [
26
- "ai-security",
27
- "mcp",
28
- "prompt-injection",
29
- "sast",
30
- "static-analysis",
31
- "supply-chain",
32
- "ci",
33
- "sarif",
34
- "llm",
35
- "agent-security",
36
- "devsecops"
37
- ],
38
- "homepage": "https://shomra.ai",
39
- "repository": {
40
- "type": "git",
41
- "url": "git+https://github.com/shomra-org/Shomra.Backend.git",
42
- "directory": "agent"
43
- },
44
- "bugs": {
45
- "url": "https://github.com/shomra-org/Shomra.Backend/issues"
46
- },
47
- "author": "Shomra",
48
- "license": "Apache-2.0",
49
- "publishConfig": {
50
- "access": "public"
51
- }
52
- }
1
+ {
2
+ "name": "@shomra/agent",
3
+ "version": "0.2.5",
4
+ "description": "Shomra — a local-first security scanner and runtime firewall for AI agents, MCP servers, prompts, and models. Gates AI artifacts in your editor and CI.",
5
+ "type": "module",
6
+ "bin": {
7
+ "shomra": "./shomra.mjs"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "scripts": {
13
+ "test": "node --test"
14
+ },
15
+ "files": [
16
+ "shomra.mjs",
17
+ "discovery.mjs",
18
+ "guard-signals.mjs",
19
+ "code-sast.mjs",
20
+ "model-refs.mjs",
21
+ "README.md",
22
+ "LICENSE",
23
+ "NOTICE"
24
+ ],
25
+ "keywords": [
26
+ "ai-security",
27
+ "mcp",
28
+ "prompt-injection",
29
+ "sast",
30
+ "static-analysis",
31
+ "supply-chain",
32
+ "ci",
33
+ "sarif",
34
+ "llm",
35
+ "agent-security",
36
+ "devsecops"
37
+ ],
38
+ "homepage": "https://shomra.ai",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/shomra-org/agent.git"
42
+ },
43
+ "bugs": {
44
+ "url": "https://github.com/shomra-org/agent/issues"
45
+ },
46
+ "author": "Shomra",
47
+ "license": "Apache-2.0",
48
+ "publishConfig": {
49
+ "access": "public"
50
+ }
51
+ }
package/shomra.mjs CHANGED
@@ -310,7 +310,7 @@ async function sendReport(cfg, assets, flags) {
310
310
  }
311
311
 
312
312
  // Human name for a key scope, inferred from its prefix. Accepts the current
313
- // `shm_` prefix and the legacy pre-rebrand `dgx_` (older keys keep working).
313
+ // `shm_` prefix and the legacy `dgx_` one, so older keys keep working.
314
314
  function keyScope(key) {
315
315
  if (!key) return null;
316
316
  if (/^(shm|dgx)_gw_/.test(key)) return 'gateway';
@@ -324,7 +324,7 @@ function cmdStatus() {
324
324
  const enrolled = !!apiKey;
325
325
  console.log(bold(cyan('\n Shomra agent')) + dim(` v${VERSION}`));
326
326
 
327
- // Mode banner — the whole point: what works right now, and what a key adds.
327
+ // Mode banner — what works right now, and what a key adds.
328
328
  if (enrolled) {
329
329
  console.log(` ${dim('Mode ')} ${green('● Enrolled')} ${dim(`(${keyScope(apiKey)} key)`)} — org policy, platform AI & dashboard telemetry active`);
330
330
  } else {
@@ -473,7 +473,7 @@ async function cmdGate(flags, positional) {
473
473
  const cfg = loadConfig();
474
474
  const { apiKey, url } = resolveSettings(cfg);
475
475
 
476
- // Batch mode: gate every AI artifact under a directory (the CI story).
476
+ // Batch mode: gate every AI artifact under a directory.
477
477
  if (flags.all) {
478
478
  return cmdGateAll(flags, positional, { apiKey, url });
479
479
  }
@@ -576,7 +576,7 @@ async function cmdGate(flags, positional) {
576
576
  // usual env var (the SDK sends it; Shomra passes it through) — or set the org
577
577
  // key on the backend and use your shm_ key as the provider key.
578
578
  //
579
- // Providers mirror the backend registry (UPSTREAM in llm-proxy.service.ts):
579
+ // Providers mirror the backend registry:
580
580
  // openai + every OpenAI-compatible API (groq/mistral/xai/deepseek/openrouter/
581
581
  // together) speak the OpenAI wire format; anthropic and gemini have their own.
582
582
 
@@ -1041,9 +1041,8 @@ async function gateArtifactList(artifacts, { apiKey, url, env, flags, root }) {
1041
1041
  prepared.push({ a, content, local, sast });
1042
1042
  }
1043
1043
 
1044
- // ── Phase 2: backend enrich, BOUNDED-PARALLEL (was one sequential round-trip
1045
- // per artifact). Order-preserving; on the first outage stop starting new
1046
- // calls (a down backend never costs N timeouts).
1044
+ // ── Phase 2: backend enrich, BOUNDED-PARALLEL. Order-preserving; on the first
1045
+ // outage stop starting new calls (a down backend never costs N timeouts).
1047
1046
  const server = new Array(prepared.length).fill(null);
1048
1047
  if (apiKey) {
1049
1048
  const conc = clampInt(process.env.SHOMRA_GATE_CONCURRENCY, 8, 1, 32);
@@ -1348,7 +1347,6 @@ function gitChangedVsBase(root, base) {
1348
1347
  const GH_LEVEL = { CRITICAL: 'failure', HIGH: 'failure', MEDIUM: 'warning', LOW: 'notice', INFO: 'notice' };
1349
1348
 
1350
1349
  async function cmdPr(flags, positional) {
1351
- // Scaffold the workflow and exit.
1352
1350
  if (flags.init) {
1353
1351
  const wf = path.resolve('.github/workflows/shomra.yml');
1354
1352
  if (fs.existsSync(wf) && !flags.force) { console.error(red('✗') + ` ${path.relative(process.cwd(), wf)} exists. Use ${bold('--force')}.`); process.exit(1); }
@@ -2106,8 +2104,8 @@ async function cmdMemoryScan(flags, positional) {
2106
2104
  //
2107
2105
  // Replays the adversarial scenario library against your OWN LLM Guard (probe
2108
2106
  // mode — nothing is persisted as a real attack) or model, scores resilience,
2109
- // and flags regressions vs the previous run. Great in CI: gate a merge/deploy
2110
- // on `--min <resilience>` and/or `--fail-on-regression`. Exit: 0 = pass,
2107
+ // and flags regressions vs the previous run. In CI, gate a merge/deploy on
2108
+ // `--min <resilience>` and/or `--fail-on-regression`. Exit: 0 = pass,
2111
2109
  // 2 = below the resilience floor or a regression appeared.
2112
2110
 
2113
2111
  async function cmdRedteam(flags) {
@@ -2241,7 +2239,7 @@ async function cmdCampaign(flags) {
2241
2239
  // signatures for whatever breached, verifies each against a benign corpus (must
2242
2240
  // catch the attack AND cause zero false positives), and — with --apply — pushes
2243
2241
  // the survivors live as a SignaturePack (no redeploy) and re-runs to prove the
2244
- // resilience lift. Great as a scheduled CI step after `shomra redteam`.
2242
+ // resilience lift. Suited to a scheduled CI step after `shomra redteam`.
2245
2243
  async function cmdHarden(flags) {
2246
2244
  const cfg = loadConfig();
2247
2245
  const { apiKey, url } = resolveSettings(cfg);
@@ -2303,8 +2301,8 @@ async function cmdHarden(flags) {
2303
2301
  // distinct principal and authorize every call against its capability policy.
2304
2302
  // Present the handle via SHOMRA_AGENT (or --agent-id); govern its capabilities,
2305
2303
  // approve break-glass requests and revoke it (a live kill-switch) in the
2306
- // dashboard. Listing/governing is JWT-only (server.approve) — not exposed to a
2307
- // machine key — so the CLI only self-registers.
2304
+ // dashboard. Listing/governing is JWT-only — not exposed to a machine key — so
2305
+ // the CLI only self-registers.
2308
2306
  async function cmdAgentIdentity(flags, positional) {
2309
2307
  const sub = (positional[0] || 'register').toLowerCase();
2310
2308
  const cfg = loadConfig();
@@ -2375,14 +2373,13 @@ function resolveAgentIdentityHandle(flags) {
2375
2373
  // Shomra LLM Guard proxy instead of a tool hook (.aider.conf.yml).
2376
2374
  // `shomra install-hook --agent <name>` writes the right shape; the installed
2377
2375
  // hook command carries `--agent <name>` so tool-guard/result-guard know which
2378
- // contract to speak at runtime. Default agent is `claude` (unqualified hooks
2379
- // installed before multi-agent support existed still work unchanged).
2376
+ // contract to speak at runtime. Default agent is `claude`, so an unqualified
2377
+ // hook invocation keeps working.
2380
2378
  //
2381
- // These hook systems are new and still moving fast if a hook silently stops
2382
- // firing after a CLI/extension update, check that agent's current docs before
2383
- // assuming Shomra is broken; each adapter is isolated below so a schema tweak
2384
- // is a small, local edit. Fail-OPEN by default (never break the session if
2385
- // the backend is down) — set SHOMRA_GUARD_STRICT=1 to fail closed.
2379
+ // Vendor hook schemas change often, and a drifted schema shows up as a hook that
2380
+ // silently stops firing. Each adapter below is isolated so tracking a change is a
2381
+ // local edit. Fail-OPEN by default (never break the session if the backend is
2382
+ // down) set SHOMRA_GUARD_STRICT=1 to fail closed.
2386
2383
 
2387
2384
  const AGENT_LABELS = {
2388
2385
  claude: 'Claude Code',
@@ -2563,7 +2560,7 @@ const AGENT_INSTALLERS = {
2563
2560
  // Cline (VS Code) is tool-dispatching like Claude Code, so it gets a real
2564
2561
  // blocking pre/post hook in the same grouped shape. Matcher covers Cline's
2565
2562
  // tool vocabulary (execute_command/write_to_file/replace_in_file/use_mcp_tool),
2566
- // all of which ToolGuardService already recognises.
2563
+ // all of which the server-side guard already recognises.
2567
2564
  cline(global) {
2568
2565
  const dir = global ? path.join(os.homedir(), '.cline') : path.join(process.cwd(), '.cline');
2569
2566
  const file = path.join(dir, 'hooks.json');
@@ -2610,9 +2607,9 @@ const AGENT_INSTALLERS = {
2610
2607
  };
2611
2608
 
2612
2609
  // Normalize each agent's own hook payload into the {tool_name, tool_input,
2613
- // tool_response, cwd, session_id} shape ToolGuardService/ToolResultGuardService
2614
- // already understand (they already recognize Cursor/Cline/Aider-style tool
2615
- // names like run_terminal_cmd/create_file — see tool-guard.service.ts).
2610
+ // tool_response, cwd, session_id} shape the server-side guards already
2611
+ // understand including Cursor/Cline/Aider-style tool names like
2612
+ // run_terminal_cmd/create_file.
2616
2613
  function normalizeGuardInput(agent, payload) {
2617
2614
  switch (agent) {
2618
2615
  case 'cursor': {
@@ -2665,7 +2662,7 @@ function normalizeGuardInput(agent, payload) {
2665
2662
 
2666
2663
  // ── tiered-guard classification (Tier 0 local vs Tier 2 escalate) ──
2667
2664
  // Paths that ARE an AI artifact — a write here is install-time behaviour the
2668
- // server's full gate must vet against org policy (mirror of PATH_KIND server-side).
2665
+ // server's full gate must vet against org policy.
2669
2666
  const ARTIFACT_PATH_RE = /(^|\/)(\.?mcp\.json|SKILL\.md|CLAUDE\.md|AGENTS\.md|GEMINI\.md|\.cursorrules|\.windsurfrules|\.aider\.conf\.yml|agent[-_]card\.json)$|(^|\/)\.claude\/(commands|agents)\/[^/]+\.md$|(^|\/)\.claude\/settings(\.local)?\.json$|(^|\/)\.well-known\/agent(-card)?\.json$|(^|\/)\.clinerules|(^|\/)\.github\/copilot-instructions\.md$/i;
2670
2667
  const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'create_file', 'str_replace_editor', 'str_replace_based_edit_tool', 'write_to_file', 'replace_in_file', 'new_rule']);
2671
2668
  const SHELL_TOOLS_RE = /^(bash|shell|sh|run_command|run_terminal_cmd|execute_command|terminal|exec)$/i;
@@ -2689,7 +2686,7 @@ function guardText(tool, input) {
2689
2686
  }
2690
2687
 
2691
2688
  // ── false-positive control: path allowlist for the runtime hooks ──────────────
2692
- // The static `shomra check` honors .shomraignore; the runtime firewall didn't.
2689
+ // Both the static `shomra check` and the runtime firewall honor .shomraignore.
2693
2690
  // A dev needs a friction-free way to mark files known-safe (the security tool's
2694
2691
  // own detection source, test fixtures, generated code) so a benign pattern in
2695
2692
  // source isn't withheld. Two layers, both keyed on the target file path: a repo
@@ -3115,9 +3112,8 @@ async function cmdResultGuard(flags) {
3115
3112
  }
3116
3113
 
3117
3114
  // Wire the runtime firewall into one or more coding agents' hook systems.
3118
- // Default (no --agent) targets Claude Code only, unchanged from before
3119
- // multi-agent support existed. `--agent cursor,windsurf` or `--agent all`
3120
- // installs into others too.
3115
+ // Default (no --agent) targets Claude Code only. `--agent cursor,windsurf` or
3116
+ // `--agent all` installs into others too.
3121
3117
  function cmdInstallHook(flags) {
3122
3118
  const global = !!flags.global;
3123
3119
  const requested = flags.agent
@@ -3163,8 +3159,8 @@ function cmdInstallHook(flags) {
3163
3159
  //
3164
3160
  // Discovers the AI tooling on this box (coding agents, MCP servers, rules files,
3165
3161
  // model keys, AI tools), locally scans the scannable ones, and prints a posture
3166
- // score + the top fixes. Zero backend needed the fastest "show a colleague"
3167
- // first-run. Pairs with `shomra protect` (unguarded agents) and `shomra check`.
3162
+ // score + the top fixes. Runs fully offline. Pairs with `shomra protect`
3163
+ // (unguarded agents) and `shomra check`.
3168
3164
  function cmdDoctor(flags) {
3169
3165
  const assets = discoverAll();
3170
3166
  const by = (t) => assets.filter((a) => a.type === t);
@@ -3240,9 +3236,8 @@ function cmdDoctor(flags) {
3240
3236
  // shomra protect [--local] [--force]
3241
3237
  //
3242
3238
  // `install-hook` protects one named agent; this discovers every supported coding
3243
- // agent on the machine and wires the Pre/Post firewall for each unguarded one
3244
- // the zero-friction "seatbelt on everything" button. Global (machine-wide) by
3245
- // default; --local scopes to this repo's .<agent> dirs.
3239
+ // agent on the machine and wires the Pre/Post firewall for each unguarded one.
3240
+ // Global (machine-wide) by default; --local scopes to this repo's .<agent> dirs.
3246
3241
  function cmdProtect(flags) {
3247
3242
  const assets = discoverAll();
3248
3243
  const labelToKey = Object.fromEntries(Object.entries(AGENT_LABELS).map(([k, v]) => [v, k]));
@@ -3280,7 +3275,7 @@ function cmdProtect(flags) {
3280
3275
  //
3281
3276
  // Generates the artifact from a least-privilege template (explicit narrow tool
3282
3277
  // grants, env-referenced secrets, https + auth on cards) and gates it to prove
3283
- // it starts clean — "the right thing is the default thing."
3278
+ // it starts clean.
3284
3279
  const NEW_TEMPLATES = {
3285
3280
  skill: (name) => ({
3286
3281
  file: path.join(name, 'SKILL.md'),
@@ -3331,7 +3326,6 @@ function cmdNew(flags, positional) {
3331
3326
  }
3332
3327
  fs.mkdirSync(path.dirname(target), { recursive: true });
3333
3328
  fs.writeFileSync(target, content);
3334
- // Prove it starts clean.
3335
3329
  const g = localGate(content, { kind: kind === 'agent-card' ? 'agent-card' : kind === 'mcp' ? 'mcp' : kind === 'rules' ? 'rules' : kind, path: file });
3336
3330
  if (flags.json) { console.log(JSON.stringify({ created: file, kind, verdict: g.verdict }, null, 2)); return; }
3337
3331
  console.log(`\n ${green('✓ Created')} ${bold(file)} ${dim(`(${kind})`)}`);
@@ -3409,7 +3403,7 @@ function worstMcpVerdict(local, idxAlert) {
3409
3403
  * the LLM can call Shomra's checks as native tools in its own loop: after it
3410
3404
  * edits files it can `shomra_check` / `shomra_scan_models`, then `shomra_fix`.
3411
3405
  * Each tool is a thin bridge to the corresponding CLI verb with `--json`, so it
3412
- * reuses the exact same engine as the CLI and editor — one engine, another face.
3406
+ * reuses the same engine as the CLI and editor.
3413
3407
  */
3414
3408
  async function cmdMcpServe(flags) {
3415
3409
  const { createInterface } = await import('node:readline');
@@ -3550,7 +3544,6 @@ async function cmdMcp(flags, positional) {
3550
3544
  return;
3551
3545
  }
3552
3546
 
3553
- // Merge into the target config.
3554
3547
  let cfg = {};
3555
3548
  if (fs.existsSync(configFile)) { try { cfg = JSON.parse(fs.readFileSync(configFile, 'utf8')); } catch { console.error(red('✗') + ` ${configFile} is not valid JSON.`); process.exit(1); } }
3556
3549
  cfg.mcpServers = cfg.mcpServers || {};