@shomra/agent 0.2.4 → 0.2.7

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,35 +83,46 @@ 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
- { name: 'Stripe live key', re: /sk_live_[0-9a-zA-Z]{16,}/ },
73
- { name: 'OpenAI key', re: /sk-[A-Za-z0-9]{20,}/ },
74
- { name: 'AWS access key id', re: /AKIA[0-9A-Z]{16}/ },
120
+ // Prefix-style keys are \b-anchored (backend parity, checks/patterns.ts): a
121
+ // slug that merely CONTAINS the prefix ("task-0123456789abcdefghij",
122
+ // "disk-…") must not read as a live credential — these are CRITICAL and BLOCK.
123
+ { name: 'Stripe live key', re: /\bsk_live_[0-9a-zA-Z]{16,}/ },
124
+ { name: 'OpenAI key', re: /\bsk-[A-Za-z0-9]{20,}/ },
125
+ { name: 'AWS access key id', re: /\bAKIA[0-9A-Z]{16}/ },
75
126
  { name: 'GitHub token', re: /ghp_[0-9A-Za-z]{20,}/ },
76
127
  { name: 'Slack token', re: /xox[baprs]-[0-9A-Za-z-]{10,}/ },
77
128
  { name: 'Generic bearer', re: /bearer\s+[A-Za-z0-9._-]{20,}/i },
@@ -84,7 +135,7 @@ export const RISKY_CONFIG_MARKERS = [
84
135
  'disable safety', 'bypass approval', 'full access', 'unrestricted',
85
136
  ];
86
137
 
87
- // ── PII (mirror of PII_PATTERNS + Luhn gate in checks/text-inspector.ts) ──
138
+ // ── PII (patterns + Luhn gate) ──
88
139
  export const PII_PATTERNS = [
89
140
  { name: 'Email address', re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/ },
90
141
  { name: 'US SSN', re: /\b\d{3}-\d{2}-\d{4}\b/ },
@@ -92,6 +143,10 @@ export const PII_PATTERNS = [
92
143
  { name: 'Phone number', re: /\b(?:\+?1[ .-]?)?\(?\d{3}\)?[ .-]?\d{3}[ .-]?\d{4}\b/ },
93
144
  { 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
145
  ];
146
+ // Reserved / RFC-1918 / doc / public-DNS IPs (not personal data), and a version
147
+ // context ("v1.0.0.0") that merely looks like an IP.
148
+ 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\.)/;
149
+ const VERSION_CONTEXT = /\b(v|ver|version|release|rev|build|semver|tag)\.?\s*$/i;
95
150
 
96
151
  // Luhn check keeps the loose credit-card regex from firing on any digit run.
97
152
  function luhnValid(value) {
@@ -123,8 +178,67 @@ export function containsAny(haystack, needles) {
123
178
  return null;
124
179
  }
125
180
 
126
- // Attacker-controlled data sinks (subset of SUSPICIOUS_EGRESS_HOSTS) a tool
127
- // call/result referencing one is an exfiltration endpoint.
181
+ // Like containsAny, but the needle must START at a word boundary — 'aws' must
182
+ // not fire inside "flaws", 'cat ' inside "concat ", 'token' is fine ("tokens"
183
+ // still hits: only the START is guarded, because these lists match prose where
184
+ // words inflect at the end). Mirrors the backend's containsWord.
185
+ const WORD_RE_CACHE = new Map();
186
+ function leadingBoundaryRe(needle) {
187
+ let re = WORD_RE_CACHE.get(needle);
188
+ if (!re) {
189
+ const esc = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
190
+ re = new RegExp(/^\w/.test(needle) ? `(?<!\\w)${esc}` : esc, 'i');
191
+ WORD_RE_CACHE.set(needle, re);
192
+ }
193
+ return re;
194
+ }
195
+ export function containsWord(haystack, needles) {
196
+ const h = String(haystack ?? '');
197
+ for (const n of needles) if (leadingBoundaryRe(n).test(h)) return n;
198
+ return null;
199
+ }
200
+
201
+ // ── risky-config: mention vs configuration ──
202
+ // `\w`-only boundaries, NOT `[\w-]`: markers legitimately butt against dashes
203
+ // (--dangerously-skip-permissions), so excluding '-' would suppress the flag
204
+ // form; excluding `\w` is what stops 'dangerously' firing on
205
+ // dangerouslySetInnerHTML. Mirrors the backend (checks/text-inspector.ts).
206
+ const MARKER_RE_CACHE = new Map();
207
+ function markerRe(marker) {
208
+ let re = MARKER_RE_CACHE.get(marker);
209
+ if (!re) {
210
+ re = new RegExp(`(?<!\\w)${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?!\\w)`, 'gi');
211
+ MARKER_RE_CACHE.set(marker, re);
212
+ }
213
+ re.lastIndex = 0; // shared instance: an early return leaves lastIndex dirty
214
+ return re;
215
+ }
216
+ const FLAG_BEFORE = /(?:^|\s)--?[\w-]*$/; // --yolo, --dangerously-skip-permissions
217
+ const ENABLE_AFTER = /^["'`\]]?\s*[:=]/; // "yolo": true, AUTO_APPROVE=1
218
+ const ENABLE_BEFORE = /[:=]\s*["'`\[]?\s*$/; // "mode": "unrestricted" — one delimiter; two (`= ['`) is a definition LIST
219
+ function isEnablement(text, at, len) {
220
+ const before = text.slice(Math.max(0, at - 24), at);
221
+ const after = text.slice(at + len, at + len + 12);
222
+ return FLAG_BEFORE.test(before) || ENABLE_AFTER.test(after) || ENABLE_BEFORE.test(before);
223
+ }
224
+ /**
225
+ * First occurrence of a risky-config marker that reads as a setting being
226
+ * ENABLED (word-bounded + enablement-shaped), or null. Unlike the backend twin
227
+ * this does NOT suppress on the mask: the CLI mask is binary (string ≡ comment),
228
+ * and JSON config keys ARE string literals — the hooks' codeContext downrank
229
+ * handles the literal/comment case instead.
230
+ */
231
+ function riskyConfigHit(text, marker) {
232
+ const re = markerRe(marker);
233
+ let m;
234
+ while ((m = re.exec(text)) !== null) {
235
+ if (isEnablement(text, m.index, m[0].length)) return { start: m.index, end: m.index + m[0].length };
236
+ }
237
+ return null;
238
+ }
239
+
240
+ // Attacker-controlled data sinks — a tool call/result referencing one is an
241
+ // exfiltration endpoint.
128
242
  export const SUSPICIOUS_EGRESS_HOSTS = [
129
243
  'webhook.site', 'requestbin', 'pipedream.net', 'ngrok.io', 'ngrok-free.app', 'ngrok.app',
130
244
  'trycloudflare.com', 'serveo.net', 'localhost.run', 'interact.sh', 'oastify.com', 'oast.pro',
@@ -138,7 +252,7 @@ export const SUSPICIOUS_EGRESS_HOSTS = [
138
252
  const SEV_RANK = { INFO: 1, LOW: 2, MEDIUM: 3, HIGH: 4, CRITICAL: 5 };
139
253
 
140
254
  // 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.
255
+ // trick are inspected too. Decoding is purely to READ the bytes; nothing runs.
142
256
  const BASE64_BLOB_RE = /\b[A-Za-z0-9+/]{32,}={0,2}/g;
143
257
  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
258
  function deobfuscate(text) {
@@ -154,11 +268,26 @@ function deobfuscate(text) {
154
268
  return { text: decoded.length ? `${text}\n${decoded.join('\n')}` : text, decodedPayload: decoded.length > 0 };
155
269
  }
156
270
 
157
- /** Reference to a known exfiltration sink host, or null. */
271
+ /**
272
+ * Reference to a known exfiltration sink host, or null. Host-boundary matched,
273
+ * NOT a raw substring — `includes('ix.io')` fired inside "matrix.io" and
274
+ * `includes('file.io')` inside "profile.io", and this feeds a HIGH/FLAG on live
275
+ * tool calls. The char before must not be a host label char (a leading '.' IS
276
+ * allowed so "paste.c-net.org" still hits); the char after must end the host.
277
+ */
278
+ const EGRESS_RE_CACHE = new Map();
279
+ function egressHostRe(host) {
280
+ let re = EGRESS_RE_CACHE.get(host);
281
+ if (!re) {
282
+ re = new RegExp(`(^|[^a-z0-9-])${host.replace(/[.]/g, '\\.')}($|[^a-z0-9.-])`, 'i');
283
+ EGRESS_RE_CACHE.set(host, re);
284
+ }
285
+ return re;
286
+ }
158
287
  export function egressHost(text) {
159
288
  if (!text) return null;
160
289
  const low = text.toLowerCase();
161
- return SUSPICIOUS_EGRESS_HOSTS.find((h) => low.includes(h)) ?? null;
290
+ return SUSPICIOUS_EGRESS_HOSTS.find((h) => egressHostRe(h).test(low)) ?? null;
162
291
  }
163
292
 
164
293
  /** 1-based line number of a character offset inside `text`. */
@@ -215,13 +344,8 @@ function codeMask(text) {
215
344
  while (i < n) {
216
345
  const c = text[i], c2 = text[i + 1];
217
346
  if (state === 0) {
218
- if (c === "'") { state = 1; mask[i++] = 1; continue; }
219
- if (c === '"') { state = 2; mask[i++] = 1; continue; }
220
- if (c === '`') { state = 3; mask[i++] = 1; continue; }
221
- if (c === '/' && c2 === '/') { state = 4; mask[i++] = 1; continue; }
222
- if (c === '#' && (i === 0 || /\s/.test(text[i - 1]))) { state = 4; mask[i++] = 1; continue; }
223
- if (c === '/' && c2 === '*') { state = 5; mask[i++] = 1; continue; }
224
- if (c === '<' && text.startsWith('<!--', i)) { state = 6; mask[i++] = 1; continue; }
347
+ // The fence test MUST precede the backtick-string test, or ``` is consumed
348
+ // as a template-literal opener and the fence handler below never runs.
225
349
  if (text.startsWith('```', i) || text.startsWith('~~~', i)) { // fenced block → mask the whole span, delimiters included
226
350
  const fence = text.slice(i, i + 3);
227
351
  const nl = text.indexOf('\n', i);
@@ -234,6 +358,13 @@ function codeMask(text) {
234
358
  for (let k = i; k < end; k++) mask[k] = 1;
235
359
  prevSig = ''; i = end; continue;
236
360
  }
361
+ if (c === "'") { state = 1; mask[i++] = 1; continue; }
362
+ if (c === '"') { state = 2; mask[i++] = 1; continue; }
363
+ if (c === '`') { state = 3; mask[i++] = 1; continue; }
364
+ if (c === '/' && c2 === '/') { state = 4; mask[i++] = 1; continue; }
365
+ if (c === '#' && (i === 0 || /\s/.test(text[i - 1]))) { state = 4; mask[i++] = 1; continue; }
366
+ if (c === '/' && c2 === '*') { state = 5; mask[i++] = 1; continue; }
367
+ if (c === '<' && text.startsWith('<!--', i)) { state = 6; mask[i++] = 1; continue; }
237
368
  if (c === '/' && REGEX_START.has(prevSig)) { state = 7; inClass = false; mask[i++] = 1; continue; }
238
369
  if (!/\s/.test(c)) prevSig = c;
239
370
  i++;
@@ -305,12 +436,27 @@ export function localScan(text, opts = {}) {
305
436
  if (cats.includes('shell')) {
306
437
  const aug = deobfuscate(t);
307
438
  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) });
439
+ 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
440
  }
310
441
  if (cats.includes('injection')) {
311
442
  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) });
443
+ // First NON-NEGATED phrase (a negation right before flips it into a hardening
444
+ // rule "never ignore previous instructions").
445
+ for (const p of INJECTION_PHRASES) {
446
+ const at = low.indexOf(p);
447
+ if (at < 0) continue;
448
+ if (PRECEDING_NEGATION.test(t.slice(Math.max(0, at - 20), at))) continue;
449
+ findings.push({ label: `Injected instruction: "${p}"`, severity: 'HIGH', category: 'injection', ...locate(t, p, mask) });
450
+ break;
451
+ }
452
+ for (const { label, re } of INJECTION_REGEXES) {
453
+ const m = t.match(re);
454
+ if (!m) continue;
455
+ const at = m.index ?? 0;
456
+ if (PRECEDING_NEGATION.test(t.slice(Math.max(0, at - 20), at))) continue;
457
+ if (label === 'Bulk destructive command' && BUILD_ARTIFACT.test(m[0])) continue; // build/test cleanup
458
+ findings.push({ label, severity: 'HIGH', category: 'injection', ...locate(t, re, mask) });
459
+ }
314
460
  if (INVISIBLE_CHARS_RE.test(t)) findings.push({ label: 'Invisible / zero-width characters', severity: 'MEDIUM', category: 'injection', ...locate(t, INVISIBLE_CHARS_RE, mask) });
315
461
  }
316
462
  if (cats.includes('secret')) {
@@ -321,12 +467,30 @@ export function localScan(text, opts = {}) {
321
467
  const m = t.match(re);
322
468
  if (!m) continue;
323
469
  if (name === 'Credit card number' && !luhnValid(m[0])) continue; // gate the loose CC regex
470
+ // Infra / reserved / doc / public-DNS IPs and version strings ("v1.0.0.0")
471
+ // are not personal data.
472
+ if (name === 'IPv4 address') {
473
+ if (RESERVED_IPV4.test(m[0])) continue;
474
+ if (VERSION_CONTEXT.test(t.slice(Math.max(0, (m.index ?? 0) - 12), m.index ?? 0))) continue;
475
+ }
476
+ // A separator-less digit run is an ID / Unix timestamp, not a phone number.
477
+ if (name === 'Phone number' && /^\d+$/.test(m[0])) continue;
324
478
  findings.push({ label: `Personal data: ${name}`, severity: 'MEDIUM', category: 'pii', ...locate(t, re, mask) });
325
479
  }
326
480
  }
327
481
  if (cats.includes('config')) {
328
- const low = t.toLowerCase();
329
- for (const m of RISKY_CONFIG_MARKERS) if (low.includes(m)) { findings.push({ label: `Risky setting: "${m}"`, severity: 'MEDIUM', category: 'config', ...locate(t, m, mask) }); break; }
482
+ // A marker counts only where a setting is being TURNED ON — `"yolo": true`,
483
+ // AUTO_APPROVE=1, --dangerously-skip-permissions not merely named:
484
+ // 'dangerously' inside dangerouslySetInnerHTML, a marker-definition array
485
+ // (this very file), "yolo mode" in prose. Word-bounded + enablement-gated,
486
+ // skipping comment/fence mentions; mirrors the backend's riskyConfigHit.
487
+ for (const m of RISKY_CONFIG_MARKERS) {
488
+ const hit = riskyConfigHit(t, m);
489
+ if (hit) {
490
+ findings.push({ label: `Risky setting: "${m}"`, severity: 'MEDIUM', category: 'config', line: lineAt(t, hit.start), codeContext: mask[hit.start] === 1 });
491
+ break;
492
+ }
493
+ }
330
494
  }
331
495
  if (cats.includes('egress')) {
332
496
  const h = egressHost(t);
@@ -352,7 +516,7 @@ export function downrankCodeContext(findings) {
352
516
  }
353
517
 
354
518
  // ── local artifact gate (offline `shomra gate`) ──
355
- // Social-engineering "install-lure" prose (mirror of INSTALL_LURE server-side).
519
+ // Social-engineering "install-lure" prose.
356
520
  const INSTALL_LURE = [
357
521
  { 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
522
  { 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 +524,7 @@ const INSTALL_LURE = [
360
524
  { 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
525
  ];
362
526
 
363
- // ── typosquat / malicious-package intel (mirror of checks/patterns.ts) ──
527
+ // ── typosquat / malicious-package intel ──
364
528
  const MALICIOUS_PACKAGE_SEED = new Set([
365
529
  'event-stream', 'eslint-scope-malware', 'electron-native-notify', 'rc-malware',
366
530
  'crossenv', 'mongose', 'expresss',
@@ -447,7 +611,7 @@ function frontmatter(text) {
447
611
  return data;
448
612
  }
449
613
 
450
- // ── structured MCP-config checks (mirror of ArtifactAnalyzerService.checkMcpConfig) ──
614
+ // ── structured MCP-config checks ──
451
615
  // Parses the JSON and inspects each server: plaintext HTTP (weak auth), a
452
616
  // hard-coded secret in the env block / launch line, and a typosquat / known-
453
617
  // malicious launch package — structural findings a raw-text scan can't produce.
@@ -486,7 +650,7 @@ function localMcp(content) {
486
650
  return out;
487
651
  }
488
652
 
489
- // ── structured agent-card checks (mirror of checkAgentCard endpoint analysis) ──
653
+ // ── structured agent-card checks ──
490
654
  // Grades every URL the card declares (assessUrl: metadata SSRF, private-network
491
655
  // pivot, plaintext, raw IP) and flags a public card with no auth scheme.
492
656
  function localAgentCard(content) {
@@ -518,7 +682,7 @@ function localAgentCard(content) {
518
682
  return out;
519
683
  }
520
684
 
521
- // ── slash-command extras (mirror of checkCommand: `!`-bang + `@`-file) ──
685
+ // ── slash-command extras (`!`-bang + `@`-file) ──
522
686
  function localCommandExtras(content) {
523
687
  const out = [];
524
688
  const body = content || '';
@@ -533,7 +697,7 @@ function localCommandExtras(content) {
533
697
  return out;
534
698
  }
535
699
 
536
- // ── memory / rules poisoning (mirror of bundle/memory-signals.ts analyzeMemory) ──
700
+ // ── memory / rules poisoning ──
537
701
  // A persistent memory note or an AI rules file (CLAUDE.md, .cursorrules, …) is
538
702
  // re-injected as high-authority context every session. This grades the two by a
539
703
  // different baseline: MEMORY should record facts (any standing directive is
@@ -587,7 +751,7 @@ function scanDirectives(text) {
587
751
  * 'MEMORY' (agent-writable scratchpad — any standing directive is anomalous) or
588
752
  * 'INSTRUCTION' (curated rules file — only universally-malicious signals count).
589
753
  * Returns findings shaped like localGate's ({ severity, title, remediationText,
590
- * line }). Faithful to bundle/memory-signals.ts analyzeMemory.
754
+ * line }).
591
755
  */
592
756
  export function localMemory(content, { kind = 'MEMORY' } = {}) {
593
757
  const text = content || '';
@@ -622,10 +786,10 @@ export function localMemory(content, { kind = 'MEMORY' } = {}) {
622
786
 
623
787
  // Executable payload / egress sink / lifecycle-hook references have no business
624
788
  // 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; }
789
+ 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
790
  const host = egressHost(text);
627
791
  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
- if (hasImperative && containsAny(text, SENSITIVE_READ) && containsAny(text, NETWORK_VERBS)) {
792
+ if (hasImperative && containsWord(text, SENSITIVE_READ) && containsWord(text, NETWORK_VERBS)) {
629
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.');
630
794
  }
631
795
  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);
@@ -645,7 +809,7 @@ export function localMemory(content, { kind = 'MEMORY' } = {}) {
645
809
  return findings.filter((f) => (seen.has(f.title) ? false : (seen.add(f.title), true)));
646
810
  }
647
811
 
648
- // Basenames of AI rules / instruction files (mirror of INSTRUCTION_BASENAMES).
812
+ // Basenames of AI rules / instruction files.
649
813
  const INSTRUCTION_BASENAMES = new Set([
650
814
  'claude.md', 'agents.md', 'agent.md', 'gemini.md', 'llms.txt', 'llms-full.txt',
651
815
  '.cursorrules', '.windsurfrules', '.clinerules', '.aiderrules', '.continuerules',
@@ -734,8 +898,8 @@ export function localGate(content, { kind, path } = {}) {
734
898
  }
735
899
 
736
900
  // 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.
901
+ // the server default policy: any CRITICAL → BLOCK, any HIGH → FLAG.
902
+ // Exported so callers that fold in extra findings (e.g.
739
903
  // the CLI merging bundled-script SAST hits) re-grade the same way.
740
904
  export function grade(findings) {
741
905
  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,51 +1,51 @@
1
- {
2
- "name": "@shomra/agent",
3
- "version": "0.2.4",
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/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
- }
1
+ {
2
+ "name": "@shomra/agent",
3
+ "version": "0.2.7",
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
@@ -15,12 +15,25 @@ import path from 'node:path';
15
15
  import os from 'node:os';
16
16
  import crypto from 'node:crypto';
17
17
  import { execSync } from 'node:child_process';
18
+ import { fileURLToPath } from 'node:url';
18
19
  import { discoverAll } from './discovery.mjs';
19
20
  import { localScan, localGate, grade, downrankCodeContext, SECRET_PATTERNS } from './guard-signals.mjs';
20
21
  import { scanSourceFile, isScannableSource, isModelConfig } from './code-sast.mjs';
21
22
  import { scanModelRefs, isModelRefScannable } from './model-refs.mjs';
22
23
 
23
- const VERSION = '0.2.0';
24
+ // Read from package.json rather than hardcoding: the two spellings drifted (this
25
+ // const said 0.2.0 while the package was already 0.2.4), so `shomra --version`
26
+ // and the `x-shomra-agent` header both under-reported the running build — which
27
+ // is exactly the value you need to trust when triaging a bad scan in the field.
28
+ // Falls back to the package version being unreadable rather than crashing the CLI.
29
+ const VERSION = (() => {
30
+ try {
31
+ const here = path.dirname(fileURLToPath(import.meta.url));
32
+ return JSON.parse(fs.readFileSync(path.join(here, 'package.json'), 'utf8')).version ?? '0.0.0';
33
+ } catch {
34
+ return '0.0.0';
35
+ }
36
+ })();
24
37
  const CONFIG_DIR = path.join(os.homedir(), '.shomra');
25
38
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
26
39
 
@@ -310,7 +323,7 @@ async function sendReport(cfg, assets, flags) {
310
323
  }
311
324
 
312
325
  // 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).
326
+ // `shm_` prefix and the legacy `dgx_` one, so older keys keep working.
314
327
  function keyScope(key) {
315
328
  if (!key) return null;
316
329
  if (/^(shm|dgx)_gw_/.test(key)) return 'gateway';
@@ -324,7 +337,7 @@ function cmdStatus() {
324
337
  const enrolled = !!apiKey;
325
338
  console.log(bold(cyan('\n Shomra agent')) + dim(` v${VERSION}`));
326
339
 
327
- // Mode banner — the whole point: what works right now, and what a key adds.
340
+ // Mode banner — what works right now, and what a key adds.
328
341
  if (enrolled) {
329
342
  console.log(` ${dim('Mode ')} ${green('● Enrolled')} ${dim(`(${keyScope(apiKey)} key)`)} — org policy, platform AI & dashboard telemetry active`);
330
343
  } else {
@@ -473,7 +486,7 @@ async function cmdGate(flags, positional) {
473
486
  const cfg = loadConfig();
474
487
  const { apiKey, url } = resolveSettings(cfg);
475
488
 
476
- // Batch mode: gate every AI artifact under a directory (the CI story).
489
+ // Batch mode: gate every AI artifact under a directory.
477
490
  if (flags.all) {
478
491
  return cmdGateAll(flags, positional, { apiKey, url });
479
492
  }
@@ -576,7 +589,7 @@ async function cmdGate(flags, positional) {
576
589
  // usual env var (the SDK sends it; Shomra passes it through) — or set the org
577
590
  // key on the backend and use your shm_ key as the provider key.
578
591
  //
579
- // Providers mirror the backend registry (UPSTREAM in llm-proxy.service.ts):
592
+ // Providers mirror the backend registry:
580
593
  // openai + every OpenAI-compatible API (groq/mistral/xai/deepseek/openrouter/
581
594
  // together) speak the OpenAI wire format; anthropic and gemini have their own.
582
595
 
@@ -1041,9 +1054,8 @@ async function gateArtifactList(artifacts, { apiKey, url, env, flags, root }) {
1041
1054
  prepared.push({ a, content, local, sast });
1042
1055
  }
1043
1056
 
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).
1057
+ // ── Phase 2: backend enrich, BOUNDED-PARALLEL. Order-preserving; on the first
1058
+ // outage stop starting new calls (a down backend never costs N timeouts).
1047
1059
  const server = new Array(prepared.length).fill(null);
1048
1060
  if (apiKey) {
1049
1061
  const conc = clampInt(process.env.SHOMRA_GATE_CONCURRENCY, 8, 1, 32);
@@ -1348,7 +1360,6 @@ function gitChangedVsBase(root, base) {
1348
1360
  const GH_LEVEL = { CRITICAL: 'failure', HIGH: 'failure', MEDIUM: 'warning', LOW: 'notice', INFO: 'notice' };
1349
1361
 
1350
1362
  async function cmdPr(flags, positional) {
1351
- // Scaffold the workflow and exit.
1352
1363
  if (flags.init) {
1353
1364
  const wf = path.resolve('.github/workflows/shomra.yml');
1354
1365
  if (fs.existsSync(wf) && !flags.force) { console.error(red('✗') + ` ${path.relative(process.cwd(), wf)} exists. Use ${bold('--force')}.`); process.exit(1); }
@@ -2106,8 +2117,8 @@ async function cmdMemoryScan(flags, positional) {
2106
2117
  //
2107
2118
  // Replays the adversarial scenario library against your OWN LLM Guard (probe
2108
2119
  // 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,
2120
+ // and flags regressions vs the previous run. In CI, gate a merge/deploy on
2121
+ // `--min <resilience>` and/or `--fail-on-regression`. Exit: 0 = pass,
2111
2122
  // 2 = below the resilience floor or a regression appeared.
2112
2123
 
2113
2124
  async function cmdRedteam(flags) {
@@ -2241,7 +2252,7 @@ async function cmdCampaign(flags) {
2241
2252
  // signatures for whatever breached, verifies each against a benign corpus (must
2242
2253
  // catch the attack AND cause zero false positives), and — with --apply — pushes
2243
2254
  // 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`.
2255
+ // resilience lift. Suited to a scheduled CI step after `shomra redteam`.
2245
2256
  async function cmdHarden(flags) {
2246
2257
  const cfg = loadConfig();
2247
2258
  const { apiKey, url } = resolveSettings(cfg);
@@ -2303,8 +2314,8 @@ async function cmdHarden(flags) {
2303
2314
  // distinct principal and authorize every call against its capability policy.
2304
2315
  // Present the handle via SHOMRA_AGENT (or --agent-id); govern its capabilities,
2305
2316
  // 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.
2317
+ // dashboard. Listing/governing is JWT-only — not exposed to a machine key — so
2318
+ // the CLI only self-registers.
2308
2319
  async function cmdAgentIdentity(flags, positional) {
2309
2320
  const sub = (positional[0] || 'register').toLowerCase();
2310
2321
  const cfg = loadConfig();
@@ -2375,14 +2386,13 @@ function resolveAgentIdentityHandle(flags) {
2375
2386
  // Shomra LLM Guard proxy instead of a tool hook (.aider.conf.yml).
2376
2387
  // `shomra install-hook --agent <name>` writes the right shape; the installed
2377
2388
  // 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).
2389
+ // contract to speak at runtime. Default agent is `claude`, so an unqualified
2390
+ // hook invocation keeps working.
2380
2391
  //
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.
2392
+ // Vendor hook schemas change often, and a drifted schema shows up as a hook that
2393
+ // silently stops firing. Each adapter below is isolated so tracking a change is a
2394
+ // local edit. Fail-OPEN by default (never break the session if the backend is
2395
+ // down) set SHOMRA_GUARD_STRICT=1 to fail closed.
2386
2396
 
2387
2397
  const AGENT_LABELS = {
2388
2398
  claude: 'Claude Code',
@@ -2563,7 +2573,7 @@ const AGENT_INSTALLERS = {
2563
2573
  // Cline (VS Code) is tool-dispatching like Claude Code, so it gets a real
2564
2574
  // blocking pre/post hook in the same grouped shape. Matcher covers Cline's
2565
2575
  // tool vocabulary (execute_command/write_to_file/replace_in_file/use_mcp_tool),
2566
- // all of which ToolGuardService already recognises.
2576
+ // all of which the server-side guard already recognises.
2567
2577
  cline(global) {
2568
2578
  const dir = global ? path.join(os.homedir(), '.cline') : path.join(process.cwd(), '.cline');
2569
2579
  const file = path.join(dir, 'hooks.json');
@@ -2610,9 +2620,9 @@ const AGENT_INSTALLERS = {
2610
2620
  };
2611
2621
 
2612
2622
  // 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).
2623
+ // tool_response, cwd, session_id} shape the server-side guards already
2624
+ // understand including Cursor/Cline/Aider-style tool names like
2625
+ // run_terminal_cmd/create_file.
2616
2626
  function normalizeGuardInput(agent, payload) {
2617
2627
  switch (agent) {
2618
2628
  case 'cursor': {
@@ -2665,7 +2675,7 @@ function normalizeGuardInput(agent, payload) {
2665
2675
 
2666
2676
  // ── tiered-guard classification (Tier 0 local vs Tier 2 escalate) ──
2667
2677
  // 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).
2678
+ // server's full gate must vet against org policy.
2669
2679
  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
2680
  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
2681
  const SHELL_TOOLS_RE = /^(bash|shell|sh|run_command|run_terminal_cmd|execute_command|terminal|exec)$/i;
@@ -2689,7 +2699,7 @@ function guardText(tool, input) {
2689
2699
  }
2690
2700
 
2691
2701
  // ── false-positive control: path allowlist for the runtime hooks ──────────────
2692
- // The static `shomra check` honors .shomraignore; the runtime firewall didn't.
2702
+ // Both the static `shomra check` and the runtime firewall honor .shomraignore.
2693
2703
  // A dev needs a friction-free way to mark files known-safe (the security tool's
2694
2704
  // own detection source, test fixtures, generated code) so a benign pattern in
2695
2705
  // source isn't withheld. Two layers, both keyed on the target file path: a repo
@@ -2833,9 +2843,17 @@ async function screenModelLoad(agent, tool, input, url) {
2833
2843
  if (!refs.length) return; // modelLookup is cache-first + breaker-aware, so don't bail here
2834
2844
 
2835
2845
  const flagged = [];
2846
+ // ONE budget for the whole screen, not one per ref. This runs inside the
2847
+ // PreToolUse hook, so its cost is added to a tool call the dev is watching: a
2848
+ // file citing five uncached models must not be able to spend 5× the guard
2849
+ // timeout. Cache hits and a tripped breaker short-circuit before any network,
2850
+ // so the common path never touches this.
2851
+ const deadline = Date.now() + guardTimeoutMs();
2836
2852
  for (const r of refs) {
2853
+ const left = deadline - Date.now();
2854
+ if (left <= 0) break; // budget spent — flag what we screened, never stall the call
2837
2855
  let lk;
2838
- try { lk = await modelLookup(url, r.id, r.revision); } catch { return; } // uncached + backend down → can't judge, stay silent
2856
+ try { lk = await modelLookup(url, r.id, r.revision, left); } catch { return; } // uncached + backend down → can't judge, stay silent
2839
2857
  const findings = (lk && lk.findings) || [];
2840
2858
  const worst = findings.reduce((m, f) => Math.max(m, MODEL_SEV_RANK[f.severity] || 0), 0);
2841
2859
  const bad = lk && lk.found && (lk.verdict === 'FAIL' || lk.verdict === 'REVIEW' || worst >= MODEL_SEV_RANK.HIGH);
@@ -3006,6 +3024,26 @@ async function cmdToolGuard(flags) {
3006
3024
  signal: ctrl.signal,
3007
3025
  });
3008
3026
  clearTimeout(timer);
3027
+ // fetch() does NOT reject on 4xx/5xx. Without this check a rejected key
3028
+ // returned its error body, r.json() parsed it happily, breakerReset() marked
3029
+ // the backend healthy, res.decision came back undefined — and every
3030
+ // escalated call silently ALLOWed. Org policy off, no error, no breaker, no
3031
+ // signal, indefinitely. Non-2xx must reach the failure path below.
3032
+ if (!r.ok) {
3033
+ // An auth failure is a misconfiguration, not an outage: it will not heal
3034
+ // on its own, so it gets a visible line rather than a 30s breaker cooldown
3035
+ // that would hide it (and skip even this warning on the calls after it).
3036
+ if (r.status === 401 || r.status === 403) {
3037
+ process.stderr.write(
3038
+ `[shomra] guard NOT enforced: the backend rejected this API key (HTTP ${r.status}). ` +
3039
+ `Local Tier-0 screening still ran; org policy, agent identity and flow control did not. ` +
3040
+ `Re-enroll with \`shomra init --key <key>\`.\n`,
3041
+ );
3042
+ if (strict) emitGuardDeny(agent, `Shomra guard could not authenticate (HTTP ${r.status}); blocked by fail-closed policy.`);
3043
+ process.exit(0);
3044
+ }
3045
+ throw new Error(`HTTP ${r.status}`); // 5xx / 429 → a real outage, trip the breaker
3046
+ }
3009
3047
  res = await r.json();
3010
3048
  breakerReset(); // healthy response — clear any tripped breaker
3011
3049
  } catch (e) {
@@ -3098,6 +3136,20 @@ async function cmdResultGuard(flags) {
3098
3136
  signal: ctrl.signal,
3099
3137
  });
3100
3138
  clearTimeout(timer);
3139
+ // See cmdToolGuard: fetch() does not reject on 4xx, so an error body would
3140
+ // parse cleanly and `res.decision` would be undefined → silent fail-open.
3141
+ if (!r.ok) {
3142
+ if (r.status === 401 || r.status === 403) {
3143
+ process.stderr.write(
3144
+ `[shomra] result-guard NOT enforced: the backend rejected this API key (HTTP ${r.status}). ` +
3145
+ `Local Tier-0 screening still ran; server-side flow taint did not. ` +
3146
+ `Re-enroll with \`shomra init --key <key>\`.\n`,
3147
+ );
3148
+ if (strict) emitResultBlock(agent, `Shomra result-guard could not authenticate (HTTP ${r.status}); blocked by fail-closed policy.`);
3149
+ process.exit(0);
3150
+ }
3151
+ throw new Error(`HTTP ${r.status}`); // 5xx / 429 → a real outage, trip the breaker
3152
+ }
3101
3153
  res = await r.json();
3102
3154
  breakerReset();
3103
3155
  } catch (e) {
@@ -3115,9 +3167,8 @@ async function cmdResultGuard(flags) {
3115
3167
  }
3116
3168
 
3117
3169
  // 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.
3170
+ // Default (no --agent) targets Claude Code only. `--agent cursor,windsurf` or
3171
+ // `--agent all` installs into others too.
3121
3172
  function cmdInstallHook(flags) {
3122
3173
  const global = !!flags.global;
3123
3174
  const requested = flags.agent
@@ -3163,8 +3214,8 @@ function cmdInstallHook(flags) {
3163
3214
  //
3164
3215
  // Discovers the AI tooling on this box (coding agents, MCP servers, rules files,
3165
3216
  // 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`.
3217
+ // score + the top fixes. Runs fully offline. Pairs with `shomra protect`
3218
+ // (unguarded agents) and `shomra check`.
3168
3219
  function cmdDoctor(flags) {
3169
3220
  const assets = discoverAll();
3170
3221
  const by = (t) => assets.filter((a) => a.type === t);
@@ -3240,9 +3291,8 @@ function cmdDoctor(flags) {
3240
3291
  // shomra protect [--local] [--force]
3241
3292
  //
3242
3293
  // `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.
3294
+ // agent on the machine and wires the Pre/Post firewall for each unguarded one.
3295
+ // Global (machine-wide) by default; --local scopes to this repo's .<agent> dirs.
3246
3296
  function cmdProtect(flags) {
3247
3297
  const assets = discoverAll();
3248
3298
  const labelToKey = Object.fromEntries(Object.entries(AGENT_LABELS).map(([k, v]) => [v, k]));
@@ -3280,7 +3330,7 @@ function cmdProtect(flags) {
3280
3330
  //
3281
3331
  // Generates the artifact from a least-privilege template (explicit narrow tool
3282
3332
  // grants, env-referenced secrets, https + auth on cards) and gates it to prove
3283
- // it starts clean — "the right thing is the default thing."
3333
+ // it starts clean.
3284
3334
  const NEW_TEMPLATES = {
3285
3335
  skill: (name) => ({
3286
3336
  file: path.join(name, 'SKILL.md'),
@@ -3331,7 +3381,6 @@ function cmdNew(flags, positional) {
3331
3381
  }
3332
3382
  fs.mkdirSync(path.dirname(target), { recursive: true });
3333
3383
  fs.writeFileSync(target, content);
3334
- // Prove it starts clean.
3335
3384
  const g = localGate(content, { kind: kind === 'agent-card' ? 'agent-card' : kind === 'mcp' ? 'mcp' : kind === 'rules' ? 'rules' : kind, path: file });
3336
3385
  if (flags.json) { console.log(JSON.stringify({ created: file, kind, verdict: g.verdict }, null, 2)); return; }
3337
3386
  console.log(`\n ${green('✓ Created')} ${bold(file)} ${dim(`(${kind})`)}`);
@@ -3409,7 +3458,7 @@ function worstMcpVerdict(local, idxAlert) {
3409
3458
  * the LLM can call Shomra's checks as native tools in its own loop: after it
3410
3459
  * edits files it can `shomra_check` / `shomra_scan_models`, then `shomra_fix`.
3411
3460
  * 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.
3461
+ * reuses the same engine as the CLI and editor.
3413
3462
  */
3414
3463
  async function cmdMcpServe(flags) {
3415
3464
  const { createInterface } = await import('node:readline');
@@ -3550,7 +3599,6 @@ async function cmdMcp(flags, positional) {
3550
3599
  return;
3551
3600
  }
3552
3601
 
3553
- // Merge into the target config.
3554
3602
  let cfg = {};
3555
3603
  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
3604
  cfg.mcpServers = cfg.mcpServers || {};
@@ -3685,7 +3733,11 @@ function modelCacheOff() { return process.env.SHOMRA_MODEL_CACHE === '0' || Stri
3685
3733
  function loadModelCache() { try { return JSON.parse(fs.readFileSync(MODEL_CACHE_FILE, 'utf8')) || {}; } catch { return {}; } }
3686
3734
  function saveModelCache(c) { try { fs.mkdirSync(CONFIG_DIR, { recursive: true }); fs.writeFileSync(MODEL_CACHE_FILE, JSON.stringify(c)); } catch { /* cache is best-effort */ } }
3687
3735
 
3688
- async function modelLookup(url, id, sha) {
3736
+ // `timeoutMs` overrides the interactive API budget. The PreToolUse hook MUST
3737
+ // pass the guard budget: this function's default is sized for a human waiting on
3738
+ // `shomra models`, and inheriting it on the hot path froze a dev's terminal for
3739
+ // 15s per uncached ref against a cold backend.
3740
+ async function modelLookup(url, id, sha, timeoutMs) {
3689
3741
  const key = `${id}@${sha || 'latest'}`;
3690
3742
  const ttl = clampInt(process.env.SHOMRA_MODEL_CACHE_TTL_MS, 7 * 24 * 3600 * 1000, 0, 365 * 24 * 3600 * 1000);
3691
3743
  const cache = modelCacheOff() ? {} : loadModelCache();
@@ -3706,7 +3758,7 @@ async function modelLookup(url, id, sha) {
3706
3758
 
3707
3759
  const q = `id=${encodeURIComponent(id)}${sha ? `&sha=${encodeURIComponent(sha)}` : ''}`;
3708
3760
  const ctrl = new AbortController();
3709
- const timer = setTimeout(() => ctrl.abort(), clampInt(process.env.SHOMRA_API_TIMEOUT_MS, 15000, 1000, 60000));
3761
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs ?? clampInt(process.env.SHOMRA_API_TIMEOUT_MS, 15000, 1000, 60000));
3710
3762
  try {
3711
3763
  const res = await fetch(`${url}/models/lookup?${q}`, { signal: ctrl.signal, headers: { Accept: 'application/json', 'User-Agent': 'shomra-agent' } });
3712
3764
  if (!res.ok) throw new Error(`HTTP ${res.status}`);