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.
- package/LICENSE +11 -0
- package/README.md +180 -0
- package/bin/cli.js +113 -0
- package/hooks/__pycache__/deliberate-commands.cpython-312.pyc +0 -0
- package/hooks/deliberate-changes.py +606 -0
- package/hooks/deliberate-commands-post.py +126 -0
- package/hooks/deliberate-commands.py +1742 -0
- package/hooks/hooks.json +29 -0
- package/hooks/setup-check.py +67 -0
- package/hooks/test_skip_commands.py +293 -0
- package/package.json +51 -0
- package/src/classifier/classify_command.py +346 -0
- package/src/classifier/embed_command.py +56 -0
- package/src/classifier/index.js +324 -0
- package/src/classifier/model-classifier.js +531 -0
- package/src/classifier/pattern-matcher.js +230 -0
- package/src/config.js +207 -0
- package/src/index.js +23 -0
- package/src/install.js +754 -0
- package/src/server.js +239 -0
- package/src/uninstall.js +198 -0
- package/training/build_classifier.py +325 -0
- package/training/expanded-command-safety.jsonl +712 -0
|
@@ -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)
|