@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 +140 -41
- package/discovery.mjs +3 -3
- package/guard-signals.mjs +115 -40
- package/model-refs.mjs +8 -0
- package/package.json +51 -52
- package/shomra.mjs +31 -38
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
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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.
|
|
25
|
-
* (`.generate` / `.invoke` /
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
|
239
|
-
|
|
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.
|
|
242
|
-
re: /\b(base64|codecs|binascii
|
|
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.
|
|
245
|
-
remediation: '
|
|
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
|
-
|
|
375
|
-
|
|
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
|
|
381
|
-
remediation: '
|
|
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
|
-
// ──
|
|
423
|
-
// Per-language patterns for the
|
|
424
|
-
// when its RHS is an LLM/model call; `execSink`
|
|
425
|
-
// tainted
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
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
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
|
616
|
-
remediation: '
|
|
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
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
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
|
-
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
23
|
-
*
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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
|
|
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
|
-
'
|
|
50
|
-
'
|
|
51
|
-
'
|
|
52
|
-
'
|
|
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|
|
|
62
|
-
{ label: '
|
|
63
|
-
{ label: 'Bulk destructive command', re: /\b(delete|remove|wipe|erase|destroy|drop|purge|
|
|
64
|
-
{ label: '
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
|
127
|
-
//
|
|
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.
|
|
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
|
|
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
|
-
|
|
313
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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 }).
|
|
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
|
|
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
|
|
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
|
|
738
|
-
//
|
|
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.
|
|
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/
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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 —
|
|
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
|
|
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
|
|
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
|
|
1045
|
-
//
|
|
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.
|
|
2110
|
-
//
|
|
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.
|
|
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
|
|
2307
|
-
//
|
|
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
|
|
2379
|
-
//
|
|
2376
|
+
// contract to speak at runtime. Default agent is `claude`, so an unqualified
|
|
2377
|
+
// hook invocation keeps working.
|
|
2380
2378
|
//
|
|
2381
|
-
//
|
|
2382
|
-
// firing
|
|
2383
|
-
//
|
|
2384
|
-
//
|
|
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
|
|
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
|
|
2614
|
-
//
|
|
2615
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
|
3119
|
-
//
|
|
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.
|
|
3167
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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 || {};
|