deliberate 1.0.1

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.
@@ -0,0 +1,346 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Classify commands using CmdCaliper embeddings + RandomForest classifier.
4
+ Called by model-classifier.js for command classification.
5
+
6
+ Usage:
7
+ python classify_command.py --base64 "base64_encoded_command"
8
+ python classify_command.py --base64 "base64_encoded_command" --model base
9
+ python classify_command.py "command to analyze"
10
+
11
+ Models available:
12
+ - small (128 MB, 384-dim) - default, ships with package
13
+ - base (419 MB, 768-dim) - better accuracy, download on demand
14
+ - large (1.3 GB, 1024-dim) - best accuracy, download on demand
15
+
16
+ Output: JSON with classification result
17
+ """
18
+
19
+ import sys
20
+ import json
21
+ import base64
22
+ import pickle
23
+ import argparse
24
+ from pathlib import Path
25
+ import numpy as np
26
+
27
+ # Paths
28
+ SCRIPT_DIR = Path(__file__).parent
29
+ MODELS_DIR = SCRIPT_DIR.parent.parent / "models"
30
+
31
+ # Model configurations
32
+ MODEL_CONFIGS = {
33
+ "small": {
34
+ "hf_id": "CyCraftAI/CmdCaliper-small",
35
+ "local_path": MODELS_DIR / "cmdcaliper-small",
36
+ "dim": 384
37
+ },
38
+ "base": {
39
+ "hf_id": "CyCraftAI/CmdCaliper-base",
40
+ "local_path": MODELS_DIR / "cmdcaliper-base",
41
+ "dim": 768
42
+ },
43
+ "large": {
44
+ "hf_id": "CyCraftAI/CmdCaliper-large",
45
+ "local_path": MODELS_DIR / "cmdcaliper-large",
46
+ "dim": 1024
47
+ }
48
+ }
49
+
50
+ # Cache loaded models
51
+ _models = {}
52
+ _classifiers = {}
53
+ _training_embeddings = {} # Cache training embeddings for similarity check
54
+
55
+ def get_model(model_size="small"):
56
+ """Load embedding model (cached)."""
57
+ if model_size in _models:
58
+ return _models[model_size]
59
+
60
+ from sentence_transformers import SentenceTransformer
61
+
62
+ config = MODEL_CONFIGS[model_size]
63
+
64
+ # Try local path first, then HuggingFace
65
+ if config["local_path"].exists():
66
+ model = SentenceTransformer(str(config["local_path"]))
67
+ else:
68
+ model = SentenceTransformer(config["hf_id"])
69
+
70
+ _models[model_size] = model
71
+ return model
72
+
73
+ def get_classifier(model_size="small"):
74
+ """Load trained RandomForest classifier for the given model size."""
75
+ if model_size in _classifiers:
76
+ return _classifiers[model_size]
77
+
78
+ # Classifier file named by model size
79
+ classifier_path = MODELS_DIR / f"classifier_{model_size}.pkl"
80
+
81
+ # Fall back to generic classifier if size-specific doesn't exist
82
+ if not classifier_path.exists():
83
+ classifier_path = MODELS_DIR / "command_classifier.pkl"
84
+
85
+ if not classifier_path.exists():
86
+ return None
87
+
88
+ with open(classifier_path, "rb") as f:
89
+ data = pickle.load(f)
90
+
91
+ _classifiers[model_size] = data
92
+ return data
93
+
94
+ def get_training_embeddings(model_size="small"):
95
+ """Load training embeddings for similarity checking."""
96
+ if model_size in _training_embeddings:
97
+ return _training_embeddings[model_size]
98
+
99
+ embeddings_path = MODELS_DIR / "malicious_embeddings.json"
100
+ if not embeddings_path.exists():
101
+ return None
102
+
103
+ with open(embeddings_path, "r") as f:
104
+ data = json.load(f)
105
+
106
+ # Combine all training embeddings with their labels
107
+ all_embeddings = []
108
+ all_labels = []
109
+ all_commands = []
110
+
111
+ for label in ["DANGEROUS", "MODERATE", "SAFE"]:
112
+ if label in data:
113
+ embeddings = data[label].get("embeddings", [])
114
+ commands = data[label].get("commands", [])
115
+ for i, emb in enumerate(embeddings):
116
+ all_embeddings.append(emb)
117
+ all_labels.append(label)
118
+ all_commands.append(commands[i] if i < len(commands) else "")
119
+
120
+ # Also load SAFE embeddings if stored separately (they're in training data)
121
+ # For now, we use what's in malicious_embeddings.json which has DANGEROUS and MODERATE
122
+
123
+ result = {
124
+ "embeddings": np.array(all_embeddings) if all_embeddings else None,
125
+ "labels": all_labels,
126
+ "commands": all_commands
127
+ }
128
+
129
+ _training_embeddings[model_size] = result
130
+ return result
131
+
132
+
133
+ def compute_similarity(embedding1, embedding2):
134
+ """Compute cosine similarity between two embeddings."""
135
+ dot_product = np.dot(embedding1, embedding2)
136
+ norm1 = np.linalg.norm(embedding1)
137
+ norm2 = np.linalg.norm(embedding2)
138
+ if norm1 == 0 or norm2 == 0:
139
+ return 0.0
140
+ return float(dot_product / (norm1 * norm2))
141
+
142
+
143
+ def find_nearest_training_example(embedding, model_size="small"):
144
+ """
145
+ Find the most similar command in the training data.
146
+
147
+ Returns:
148
+ dict with: max_similarity, nearest_command, nearest_label, coverage_score
149
+
150
+ coverage_score: How well the training data covers this command
151
+ - > 0.85: Very similar to training data, trust classifier
152
+ - 0.70-0.85: Moderately similar, classifier likely reliable
153
+ - 0.50-0.70: Low similarity, consider LLM fallback
154
+ - < 0.50: Very different from training, definitely use LLM
155
+ """
156
+ training_data = get_training_embeddings(model_size)
157
+
158
+ if training_data is None or training_data["embeddings"] is None:
159
+ return {
160
+ "max_similarity": 0.0,
161
+ "nearest_command": None,
162
+ "nearest_label": None,
163
+ "coverage_score": 0.0,
164
+ "needs_llm_fallback": True,
165
+ "reason": "No training data available"
166
+ }
167
+
168
+ # Compute similarities to all training examples
169
+ similarities = []
170
+ for train_emb in training_data["embeddings"]:
171
+ sim = compute_similarity(embedding, train_emb)
172
+ similarities.append(sim)
173
+
174
+ similarities = np.array(similarities)
175
+ max_idx = np.argmax(similarities)
176
+ max_similarity = float(similarities[max_idx])
177
+
178
+ # Compute coverage score (how well training data covers this input)
179
+ # Use top-5 similarities to get a more robust measure
180
+ top_k = min(5, len(similarities))
181
+ top_similarities = np.sort(similarities)[-top_k:]
182
+ coverage_score = float(np.mean(top_similarities))
183
+
184
+ # Determine if we need LLM fallback
185
+ needs_llm = coverage_score < 0.70
186
+
187
+ return {
188
+ "max_similarity": max_similarity,
189
+ "nearest_command": training_data["commands"][max_idx],
190
+ "nearest_label": training_data["labels"][max_idx],
191
+ "coverage_score": coverage_score,
192
+ "needs_llm_fallback": needs_llm,
193
+ "reason": _get_coverage_reason(coverage_score)
194
+ }
195
+
196
+
197
+ def _get_coverage_reason(coverage_score):
198
+ """Get human-readable reason for coverage score."""
199
+ if coverage_score >= 0.85:
200
+ return "Command very similar to training data - high confidence"
201
+ elif coverage_score >= 0.70:
202
+ return "Command moderately similar to training data - good confidence"
203
+ elif coverage_score >= 0.50:
204
+ return "Command has low similarity to training data - consider LLM verification"
205
+ else:
206
+ return "Command very different from training data - LLM fallback recommended"
207
+
208
+
209
+ def get_embedding(command: str, model_size="small") -> np.ndarray:
210
+ """Generate embedding for a command."""
211
+ model = get_model(model_size)
212
+ embedding = model.encode(command, convert_to_numpy=True)
213
+ return embedding
214
+
215
+ def classify_command(command: str, model_size="small") -> dict:
216
+ """
217
+ Classify a command using CmdCaliper embeddings + RandomForest.
218
+
219
+ Returns:
220
+ dict with keys: risk, confidence, reason, probabilities, coverage info
221
+
222
+ Active Learning Support:
223
+ - coverage_score: How well training data covers this command (0-1)
224
+ - needs_llm_fallback: Whether LLM should verify this classification
225
+ - nearest_command: Most similar command in training data
226
+ """
227
+ # Get embedding
228
+ embedding = get_embedding(command, model_size)
229
+
230
+ # Check how well training data covers this command
231
+ coverage_info = find_nearest_training_example(embedding, model_size)
232
+
233
+ # Get classifier
234
+ classifier_data = get_classifier(model_size)
235
+
236
+ if classifier_data is None:
237
+ # No classifier available - return embedding only
238
+ return {
239
+ "embedding": embedding.tolist(),
240
+ "risk": None,
241
+ "reason": "No classifier available - embedding only mode",
242
+ "needs_llm_fallback": True,
243
+ **coverage_info
244
+ }
245
+
246
+ classifier = classifier_data["classifier"]
247
+ label_encoder = classifier_data["label_encoder"]
248
+
249
+ # Predict
250
+ embedding_2d = embedding.reshape(1, -1)
251
+ prediction = classifier.predict(embedding_2d)[0]
252
+ probabilities = classifier.predict_proba(embedding_2d)[0]
253
+
254
+ # Decode label
255
+ risk = label_encoder.inverse_transform([prediction])[0]
256
+
257
+ # Get confidence for predicted class
258
+ confidence = float(probabilities[prediction])
259
+
260
+ # Build probability dict
261
+ prob_dict = {}
262
+ for i, label in enumerate(label_encoder.classes_):
263
+ prob_dict[label] = float(probabilities[i])
264
+
265
+ # Determine if we need LLM fallback based on multiple factors:
266
+ # 1. Low coverage score (command unlike training data)
267
+ # 2. Low classifier confidence
268
+ # 3. Close probabilities between classes (uncertainty)
269
+ sorted_probs = sorted(probabilities, reverse=True)
270
+ prob_margin = sorted_probs[0] - sorted_probs[1] if len(sorted_probs) > 1 else 1.0
271
+
272
+ needs_llm = (
273
+ coverage_info["needs_llm_fallback"] or # Low similarity to training data
274
+ confidence < 0.60 or # Low classifier confidence
275
+ prob_margin < 0.20 # Classes too close (uncertain)
276
+ )
277
+
278
+ # Generate reason with coverage context
279
+ if needs_llm:
280
+ if coverage_info["coverage_score"] < 0.50:
281
+ reason = f"Command unfamiliar to classifier (coverage: {coverage_info['coverage_score']*100:.0f}%) - LLM verification recommended"
282
+ elif confidence < 0.60:
283
+ reason = f"Classifier uncertain ({confidence*100:.1f}% confidence) - LLM verification recommended"
284
+ else:
285
+ reason = f"Close call between classes (margin: {prob_margin*100:.0f}%) - LLM verification recommended"
286
+ else:
287
+ if risk == "DANGEROUS":
288
+ reason = f"Classifier detected dangerous pattern ({confidence*100:.1f}% confidence, coverage: {coverage_info['coverage_score']*100:.0f}%)"
289
+ elif risk == "MODERATE":
290
+ reason = f"Classifier detected moderate risk ({confidence*100:.1f}% confidence, coverage: {coverage_info['coverage_score']*100:.0f}%)"
291
+ else:
292
+ reason = f"Classifier determined command is safe ({confidence*100:.1f}% confidence, coverage: {coverage_info['coverage_score']*100:.0f}%)"
293
+
294
+ return {
295
+ "risk": risk,
296
+ "confidence": float(confidence),
297
+ "reason": reason,
298
+ "probabilities": prob_dict,
299
+ "model_size": model_size,
300
+ # Active learning fields - ensure Python native types for JSON serialization
301
+ "needs_llm_fallback": bool(needs_llm),
302
+ "coverage_score": float(coverage_info["coverage_score"]),
303
+ "nearest_command": coverage_info["nearest_command"],
304
+ "nearest_label": coverage_info["nearest_label"],
305
+ "max_similarity": float(coverage_info["max_similarity"]),
306
+ # Don't include full embedding to reduce output size
307
+ # "embedding": embedding.tolist(),
308
+ }
309
+
310
+ def main():
311
+ parser = argparse.ArgumentParser(description="Classify shell commands")
312
+ parser.add_argument("command", nargs="?", help="Command to classify")
313
+ parser.add_argument("--base64", "-b", help="Base64 encoded command")
314
+ parser.add_argument("--model", "-m", choices=["small", "base", "large"],
315
+ default="small", help="Model size to use")
316
+ parser.add_argument("--embed-only", action="store_true",
317
+ help="Only return embedding, skip classification")
318
+
319
+ args = parser.parse_args()
320
+
321
+ # Get command from args
322
+ if args.base64:
323
+ try:
324
+ command = base64.b64decode(args.base64).decode("utf-8")
325
+ except Exception as e:
326
+ print(json.dumps({"error": f"Invalid base64: {e}"}))
327
+ sys.exit(1)
328
+ elif args.command:
329
+ command = args.command
330
+ else:
331
+ print(json.dumps({"error": "No command provided"}))
332
+ sys.exit(1)
333
+
334
+ try:
335
+ if args.embed_only:
336
+ embedding = get_embedding(command, args.model)
337
+ print(json.dumps(embedding.tolist()))
338
+ else:
339
+ result = classify_command(command, args.model)
340
+ print(json.dumps(result))
341
+ except Exception as e:
342
+ print(json.dumps({"error": str(e)}))
343
+ sys.exit(1)
344
+
345
+ if __name__ == "__main__":
346
+ main()
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Generate CmdCaliper embeddings for commands.
4
+ Called by model-classifier.js for command classification.
5
+
6
+ Usage: python embed_command.py --base64 "base64_encoded_command"
7
+ python embed_command.py "command to analyze"
8
+ Output: JSON array of 384 floats (the embedding)
9
+ """
10
+
11
+ import sys
12
+ import json
13
+ import base64
14
+ from pathlib import Path
15
+
16
+ # Use the local model copy
17
+ MODEL_PATH = Path(__file__).parent.parent.parent / "models" / "cmdcaliper-small"
18
+
19
+ # Cache the model to avoid reloading
20
+ _model = None
21
+
22
+ def get_model():
23
+ """Load model once and cache it."""
24
+ global _model
25
+ if _model is None:
26
+ from sentence_transformers import SentenceTransformer
27
+ _model = SentenceTransformer(str(MODEL_PATH))
28
+ return _model
29
+
30
+ def get_embedding(command: str) -> list[float]:
31
+ """Generate embedding for a command using CmdCaliper."""
32
+ model = get_model()
33
+ embedding = model.encode(command, convert_to_numpy=True)
34
+ return embedding.tolist()
35
+
36
+ if __name__ == "__main__":
37
+ if len(sys.argv) < 2:
38
+ print(json.dumps({"error": "No command provided"}))
39
+ sys.exit(1)
40
+
41
+ # Check for base64 flag (safer input method)
42
+ if sys.argv[1] == "--base64" and len(sys.argv) >= 3:
43
+ try:
44
+ command = base64.b64decode(sys.argv[2]).decode('utf-8')
45
+ except Exception as e:
46
+ print(json.dumps({"error": f"Invalid base64: {e}"}))
47
+ sys.exit(1)
48
+ else:
49
+ command = sys.argv[1]
50
+
51
+ try:
52
+ embedding = get_embedding(command)
53
+ print(json.dumps(embedding))
54
+ except Exception as e:
55
+ print(json.dumps({"error": str(e)}))
56
+ sys.exit(1)