cli-enforcement 0.1.0__py3-none-any.whl

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.
Files changed (43) hide show
  1. cli_enforcement/__init__.py +10 -0
  2. cli_enforcement/cli.py +58 -0
  3. cli_enforcement/deploy.py +393 -0
  4. cli_enforcement/engine/__init__.py +0 -0
  5. cli_enforcement/engine/achievements.py +215 -0
  6. cli_enforcement/engine/bash_gate.py +456 -0
  7. cli_enforcement/engine/cascade_manager.py +745 -0
  8. cli_enforcement/engine/check_dismissal.py +182 -0
  9. cli_enforcement/engine/config_change.py +104 -0
  10. cli_enforcement/engine/enforce_check.py +645 -0
  11. cli_enforcement/engine/enforcement_cli.py +470 -0
  12. cli_enforcement/engine/enforcement_logger.py +209 -0
  13. cli_enforcement/engine/enforcement_unlock.py +171 -0
  14. cli_enforcement/engine/flush_reads.py +56 -0
  15. cli_enforcement/engine/instructions_loaded.py +92 -0
  16. cli_enforcement/engine/integrity_check.py +293 -0
  17. cli_enforcement/engine/message_generator.py +334 -0
  18. cli_enforcement/engine/message_generator_v2.py +150 -0
  19. cli_enforcement/engine/permission_check.py +137 -0
  20. cli_enforcement/engine/points_config.yaml +40 -0
  21. cli_enforcement/engine/post_bash_check.py +236 -0
  22. cli_enforcement/engine/post_compact.py +110 -0
  23. cli_enforcement/engine/post_edit_validate.py +543 -0
  24. cli_enforcement/engine/pre_compact.py +83 -0
  25. cli_enforcement/engine/project_integrity.py +217 -0
  26. cli_enforcement/engine/record_edit.py +205 -0
  27. cli_enforcement/engine/record_read.py +403 -0
  28. cli_enforcement/engine/session_end.py +278 -0
  29. cli_enforcement/engine/session_init.py +238 -0
  30. cli_enforcement/engine/state_manager.py +2250 -0
  31. cli_enforcement/engine/stop_check.py +233 -0
  32. cli_enforcement/engine/stop_failure.py +57 -0
  33. cli_enforcement/engine/subagent_start.py +90 -0
  34. cli_enforcement/engine/subagent_stop.py +106 -0
  35. cli_enforcement/engine/task_completed.py +53 -0
  36. cli_enforcement/engine/tool_failure.py +97 -0
  37. cli_enforcement/engine/workflow_server.py +1820 -0
  38. cli_enforcement/fleet.py +125 -0
  39. cli_enforcement-0.1.0.dist-info/METADATA +76 -0
  40. cli_enforcement-0.1.0.dist-info/RECORD +43 -0
  41. cli_enforcement-0.1.0.dist-info/WHEEL +4 -0
  42. cli_enforcement-0.1.0.dist-info/entry_points.txt +2 -0
  43. cli_enforcement-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,456 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Bash Gate - PreToolUse Hook for Bash
4
+ =====================================
5
+ Blocks Bash commands that tamper with enforcement files.
6
+
7
+ PROTECTED FILES:
8
+ - .unified_state.json (points, workflow)
9
+ - .file_reads.json (anti-hallucination)
10
+ - .edits_since_approval (edit tracking)
11
+ - .reads_this_session, .claude_md_read_this_session (markers)
12
+ - enforce_check.py, agent_gate.py, state_manager.py (scripts)
13
+ - enforcement_unlock.py, bash_gate.py (this script)
14
+ - points_config.yaml, settings.json (config)
15
+
16
+ BYPASS: User says "unlock enforcement" (sets flag via hook).
17
+
18
+ HOOK PROTOCOL:
19
+ - Exit 0 = ALLOW
20
+ - Exit 2 = BLOCK
21
+
22
+ SANITIZATION (updatedInput):
23
+ - Dangerous flags (--force, --hard, -f) are stripped instead of hard-blocking
24
+ - The sanitized command is returned via updatedInput so Claude runs the safe version
25
+ """
26
+
27
+ import json
28
+ import os
29
+ import sys
30
+ import time
31
+
32
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
33
+
34
+ from state_manager import get_manager
35
+
36
+ try:
37
+ import enforcement_logger as elog
38
+ except ImportError:
39
+ elog = None
40
+
41
+ # Patterns that identify enforcement files (substring match in command)
42
+ PROTECTED_PATTERNS = [
43
+ ".unified_state",
44
+ ".file_reads.json",
45
+ ".edits_since_approval",
46
+ ".reads_this_session",
47
+ ".claude_md_read_this_session",
48
+ ".reads_pending_batch",
49
+ ".enforcement_checksums",
50
+ ".hard_stop",
51
+ ".project_checksums",
52
+ ".sandbox_file_hashes",
53
+ "sandbox_hook.py",
54
+ "enforce_check.py",
55
+ "state_manager.py",
56
+ "enforcement_unlock.py",
57
+ "bash_gate.py",
58
+ "record_read.py",
59
+ "record_edit.py",
60
+ "post_edit_validate.py",
61
+ "check_dismissal.py",
62
+ "session_init.py",
63
+ "session_end.py",
64
+ "stop_check.py",
65
+ "flush_reads.py",
66
+ "cascade_manager.py",
67
+ "project_integrity.py",
68
+ "post_bash_check.py",
69
+ "integrity_check.py",
70
+ "enforcement_cli.py",
71
+ "workflow_server.py",
72
+ "settings.json",
73
+ "project_config.yaml",
74
+ ]
75
+
76
+ # Protected DIRECTORIES - any write targeting these dirs is blocked
77
+ # even without naming a specific file (prevents rm -rf .claude/mcp/)
78
+ # ORDER MATTERS: most specific first, most general last.
79
+ # ".claude" last = catches glob/expansion attacks on .claude/ root files
80
+ # (e.g., rm .claude/.u* which bypasses PROTECTED_PATTERNS substring matching)
81
+ PROTECTED_DIRS = [
82
+ ".claude/mcp",
83
+ ".claude/config",
84
+ ".claude/skills",
85
+ ".claude",
86
+ ]
87
+
88
+ # Commands that DIRECTLY WRITE to a file (must be followed by the protected pattern)
89
+ # These are checked with "indicator.*protected_pattern" regex, not simple substring
90
+ DIRECT_WRITE_COMMANDS = [
91
+ r"rm\s+(-\w+\s+)*", # rm [-flags] <protected>
92
+ r"rm\s+-", # rm -rf <protected>
93
+ r"mv\s+\S+\s+", # mv <source> <protected>
94
+ r"cp\s+\S+\s+", # cp <source> <protected>
95
+ r"cp\s+(-\w+\s+)+", # cp [-flags] ... <protected>
96
+ r"install\s+", # install <source> <protected>
97
+ r"rsync\s+", # rsync ... <protected>
98
+ r"sed\s+-i", # sed -i <protected>
99
+ r"tee\s+(-\w+\s+)*", # tee [-flags] <protected>
100
+ r"chmod\s+\S+\s+", # chmod mode <protected>
101
+ r"chown\s+\S+\s+", # chown owner <protected>
102
+ r"truncate\s+", # truncate <protected>
103
+ r"unlink\s+", # unlink <protected>
104
+ r"dd\s+.*of=", # dd of=<protected>
105
+ r"ln\s+(-\w+\s+)*", # ln [-sf] <target> <protected>
106
+ r"patch\s+", # patch <protected>
107
+ r"wget\s+.*-O\s*", # wget -O <protected>
108
+ r"curl\s+.*-o\s*", # curl -o <protected>
109
+ r"shred\s+", # shred <protected>
110
+ r"git\s+checkout\s+.*--\s*", # git checkout -- <protected>
111
+ r"git\s+restore\s+", # git restore <protected>
112
+ r"tar\s+.*x", # tar extract (may overwrite <protected>)
113
+ r"unzip\s+", # unzip (may overwrite <protected>)
114
+ r"cpio\s+", # cpio extract
115
+ r"vi\s+", # vi <protected>
116
+ r"vim\s+", # vim <protected>
117
+ r"nano\s+", # nano <protected>
118
+ r"ed\s+", # ed <protected>
119
+ r"ex\s+", # ex <protected>
120
+ r"find\s+.*-delete", # find -delete
121
+ r"find\s+.*-exec\s+rm", # find -exec rm
122
+ r"xargs\s+.*rm", # xargs rm
123
+ r"sort\s+.*-o\s*", # sort -o <protected>
124
+ r"touch\s+", # touch <protected> (changes mtime)
125
+ ]
126
+
127
+ # Redirect patterns: check if output redirects TO a protected file
128
+ # These use ">" or ">>" followed by the protected pattern
129
+ REDIRECT_PATTERN = r"[12]?\s*>{1,2}\s*"
130
+
131
+ # Inline code execution that can write anything
132
+ INLINE_CODE_INDICATORS = [
133
+ "python3 -c",
134
+ "python -c",
135
+ "python3 <<",
136
+ "python <<",
137
+ # Removed "python3 -" (M2 fix): was too broad, matched python3 -m, python3 --version
138
+ "bash -c",
139
+ "sh -c",
140
+ "perl -e",
141
+ "perl -p",
142
+ "ruby -e",
143
+ "node -e",
144
+ "php -r",
145
+ "lua -e",
146
+ "awk -f",
147
+ ]
148
+
149
+
150
+ def sanitize_command(command):
151
+ """Sanitize dangerous flags from commands instead of hard blocking.
152
+
153
+ Returns (sanitized_command, list_of_modifications).
154
+ If no modifications needed, returns (command, []).
155
+ """
156
+ import re
157
+ sanitized = command
158
+ modifications = []
159
+
160
+ # Strip --force from git push
161
+ if re.search(r'git\s+push.*--force', command):
162
+ sanitized = re.sub(r'\s*--force\b', '', sanitized)
163
+ modifications.append("Removed --force from git push")
164
+
165
+ # Strip --hard from git reset
166
+ if re.search(r'git\s+reset.*--hard', command):
167
+ sanitized = re.sub(r'\s*--hard\b', '', sanitized)
168
+ modifications.append("Removed --hard from git reset")
169
+
170
+ # Strip -f from rm (but not rm -rf / which stays blocked)
171
+ if re.search(r'\brm\s+-[^/]*f', command) and not re.search(r'rm\s+-rf\s+/', command):
172
+ sanitized = re.sub(r'(\brm\s+)-([a-zA-Z]*)f([a-zA-Z]*)', r'\1-\2\3', sanitized)
173
+ modifications.append("Removed -f flag from rm")
174
+
175
+ return sanitized, modifications
176
+
177
+
178
+ def deny(reason):
179
+ """BLOCK the bash command via exit 0 + JSON deny.
180
+
181
+ FIX API-C2: Exit 2 causes Claude Code to IGNORE all JSON output.
182
+ Must use exit 0 + JSON deny so Claude sees the structured reason.
183
+ """
184
+ if elog:
185
+ elog.log_decision("bash_gate", False, reason)
186
+ output = {
187
+ "hookSpecificOutput": {
188
+ "hookEventName": "PreToolUse",
189
+ "permissionDecision": "deny",
190
+ "permissionDecisionReason": reason
191
+ }
192
+ }
193
+ print(json.dumps(output))
194
+ print(reason, file=sys.stderr)
195
+ sys.exit(0)
196
+
197
+
198
+ def allow():
199
+ """ALLOW the bash command."""
200
+ if elog:
201
+ elog.log_decision("bash_gate", True, "safe_command")
202
+ output = {
203
+ "hookSpecificOutput": {
204
+ "hookEventName": "PreToolUse",
205
+ "permissionDecision": "allow"
206
+ }
207
+ }
208
+ print(json.dumps(output))
209
+ sys.exit(0)
210
+
211
+
212
+ def allow_sanitized(sanitized_command, modifications):
213
+ """ALLOW the bash command with a sanitized version via updatedInput."""
214
+ if elog:
215
+ elog.log_decision("bash_gate", True, "sanitized: " + "; ".join(modifications))
216
+ output = {
217
+ "hookSpecificOutput": {
218
+ "hookEventName": "PreToolUse",
219
+ "permissionDecision": "allow",
220
+ "permissionDecisionReason": "Command sanitized: " + "; ".join(modifications),
221
+ "updatedInput": {"command": sanitized_command},
222
+ "additionalContext": "WARNING: Your command was modified for safety: " + "; ".join(modifications)
223
+ }
224
+ }
225
+ print(json.dumps(output))
226
+ print("Command sanitized: " + "; ".join(modifications), file=sys.stderr)
227
+ sys.exit(0)
228
+
229
+
230
+ def main():
231
+ """Gate Bash commands that target enforcement files."""
232
+ # Read hook JSON from stdin with FIX #3: Size limit + error context
233
+ try:
234
+ raw = sys.stdin.read()
235
+ if not raw:
236
+ deny("BLOCKED: bash_gate.py received empty stdin (pipe broken?)")
237
+ # FIX #3: CRITICAL - Prevent JSON DoS with size limit
238
+ if len(raw) > 100000: # 100KB limit
239
+ deny("BLOCKED: bash_gate.py input exceeded 100KB limit (command too long?)")
240
+ hook_input = json.loads(raw)
241
+ except json.JSONDecodeError as e:
242
+ deny(f"BLOCKED: bash_gate.py JSON parse error at position {e.pos}: {e.msg}")
243
+ except (IOError, OSError) as e:
244
+ deny(f"BLOCKED: bash_gate.py stdin read failed: {e}")
245
+
246
+ # Extract the command
247
+ tool_input = hook_input.get("tool_input", {})
248
+ if isinstance(tool_input, dict):
249
+ command = tool_input.get("command", "")
250
+ else:
251
+ command = str(tool_input)
252
+
253
+ if not command:
254
+ allow()
255
+
256
+ import re
257
+
258
+ # ANTI-SELF-HEALING: Block the AI from running recovery commands.
259
+ # Only the USER can recover points or reset enforcement (via user prompt).
260
+ # The AI running these would defeat the entire purpose of enforcement.
261
+ SELF_HEALING_PATTERNS = [
262
+ (r"enforcement_cli\.py\s+points\s+recover", "points recovery"),
263
+ (r"enforcement_cli\.py\s+reset", "hard_stop reset"),
264
+ (r"integrity_check\.py\s+rebaseline", "checksum rebaseline"),
265
+ ]
266
+
267
+ # PERSISTENCE ATTACKS: Block commands that install persistent backdoors
268
+ PERSISTENCE_PATTERNS = [
269
+ (r"crontab", "cron job installation"),
270
+ (r"\bat\s+", "at job scheduling"),
271
+ (r"systemctl\s+(enable|start|restart)", "systemd service manipulation"),
272
+ (r"nohup\s+.*&", "background process persistence"),
273
+ ]
274
+ for pattern, description in PERSISTENCE_PATTERNS:
275
+ if re.search(pattern, command):
276
+ deny("BLOCKED: " + description + " not allowed in sandbox.")
277
+ for pattern, description in SELF_HEALING_PATTERNS:
278
+ if re.search(pattern, command):
279
+ # Check if user explicitly unlocked enforcement
280
+ mgr = get_manager()
281
+ state = mgr.load()
282
+ unlock = state.get("enforcement_unlocked", {})
283
+ if unlock.get("active") and time.time() < unlock.get("expires_at", 0):
284
+ break # User unlocked - allow
285
+ deny(
286
+ "BLOCKED: AI cannot self-heal (" + description + "). "
287
+ "Only the user can recover points or reset enforcement. "
288
+ "Ask the user to type 'recover points' or 'unlock enforcement'."
289
+ )
290
+
291
+ # Check 0: DIRECTORY-LEVEL PROTECTION
292
+ # Block destructive commands targeting enforcement directories
293
+ # even without naming a specific file (prevents rm -rf .claude/mcp/)
294
+ for protected_dir in PROTECTED_DIRS:
295
+ if protected_dir in command:
296
+ # Check if this is a destructive operation on the directory
297
+ esc_dir = re.escape(protected_dir)
298
+ dir_write_patterns = [
299
+ r"rm\s+(-\w+\s+)*.*" + esc_dir,
300
+ r"rm\s+-\w*r\w*\s+.*" + esc_dir,
301
+ r"mv\s+.*" + esc_dir,
302
+ r"chmod\s+-\w*R\w*\s+.*" + esc_dir,
303
+ r"chown\s+-\w*R\w*\s+.*" + esc_dir,
304
+ r"rsync\s+.*--delete.*" + esc_dir,
305
+ # find-based destruction (dir appears BEFORE -delete/-exec)
306
+ r"find\s+" + esc_dir + r".*-delete",
307
+ r"find\s+" + esc_dir + r".*-exec\s+rm",
308
+ r"find\s+.*" + esc_dir + r".*-delete",
309
+ r"find\s+.*" + esc_dir + r".*-exec\s+rm",
310
+ # Pipe chains: dir referenced then piped to destructive command
311
+ esc_dir + r".*\|\s*xargs\s+.*rm",
312
+ esc_dir + r".*\|\s*xargs\s+.*\brm\b",
313
+ # Pipe to shell execution (echo "rm ..." | bash)
314
+ esc_dir + r".*\|\s*(?:bash|sh|zsh)\b",
315
+ # Loop-based destruction
316
+ r"for\s+.*" + esc_dir + r".*\brm\b",
317
+ r"while\s+.*" + esc_dir + r".*\brm\b",
318
+ ]
319
+ for dp in dir_write_patterns:
320
+ if re.search(dp, command):
321
+ # Check unlock
322
+ mgr = get_manager()
323
+ state = mgr.load()
324
+ unlock = state.get("enforcement_unlocked", {})
325
+ if unlock.get("active") and time.time() < unlock.get("expires_at", 0):
326
+ break # Unlocked - allow
327
+ deny(
328
+ "BLOCKED: Destructive command targets enforcement directory '"
329
+ + protected_dir + "'. Say 'unlock enforcement' to allow."
330
+ )
331
+
332
+ # Check 0.5: REDIRECT/WRITE into protected directories
333
+ # Catches: echo x > .claude/mcp/evil.py, cp x .claude/skills/y
334
+ for protected_dir in PROTECTED_DIRS:
335
+ # Redirect into protected dir
336
+ if re.search(REDIRECT_PATTERN + r"\S*" + re.escape(protected_dir), command):
337
+ mgr = get_manager()
338
+ state = mgr.load()
339
+ unlock = state.get("enforcement_unlocked", {})
340
+ if not (unlock.get("active") and time.time() < unlock.get("expires_at", 0)):
341
+ deny(
342
+ "BLOCKED: Redirect writes into enforcement directory '"
343
+ + protected_dir + "'. Say 'unlock enforcement' to allow."
344
+ )
345
+ # cp/install/rsync INTO protected dir
346
+ write_into_patterns = [
347
+ r"cp\s+\S+\s+\S*" + re.escape(protected_dir),
348
+ r"install\s+\S+\s+\S*" + re.escape(protected_dir),
349
+ r"rsync\s+.*\s+" + re.escape(protected_dir),
350
+ r"tee\s+\S*" + re.escape(protected_dir),
351
+ ]
352
+ for wip in write_into_patterns:
353
+ if re.search(wip, command):
354
+ mgr = get_manager()
355
+ state = mgr.load()
356
+ unlock = state.get("enforcement_unlocked", {})
357
+ if not (unlock.get("active") and time.time() < unlock.get("expires_at", 0)):
358
+ deny(
359
+ "BLOCKED: Write into enforcement directory '"
360
+ + protected_dir + "'. Say 'unlock enforcement' to allow."
361
+ )
362
+
363
+ # Check if command references any protected file
364
+ matched_pattern = None
365
+ for pattern in PROTECTED_PATTERNS:
366
+ if pattern in command:
367
+ matched_pattern = pattern
368
+ break
369
+
370
+ if matched_pattern is None:
371
+ # Not touching enforcement files — check if command needs sanitization
372
+ sanitized, mods = sanitize_command(command)
373
+ if mods:
374
+ allow_sanitized(sanitized, mods)
375
+ allow() # Not touching enforcement files, no sanitization needed
376
+
377
+ is_write = False
378
+ matched_write = None
379
+
380
+ # Check 1: Direct write commands targeting a protected file
381
+ for cmd_pattern in DIRECT_WRITE_COMMANDS:
382
+ if re.search(cmd_pattern + r".*" + re.escape(matched_pattern), command):
383
+ is_write = True
384
+ matched_write = cmd_pattern.split(r"\s")[0]
385
+ break
386
+
387
+ # Check 2: Redirect output TO a protected file (> or >> before protected name)
388
+ if not is_write:
389
+ if re.search(REDIRECT_PATTERN + r"\S*" + re.escape(matched_pattern), command):
390
+ is_write = True
391
+ matched_write = "redirect >"
392
+
393
+ # Check 3: Inline python code + protected file (can write anything)
394
+ if not is_write:
395
+ for indicator in INLINE_CODE_INDICATORS:
396
+ if indicator in command:
397
+ is_write = True
398
+ matched_write = indicator
399
+ break
400
+
401
+ # Check 4: READ commands on enforcement files (exfiltration protection)
402
+ # Agent shouldn't read enforcement state via bash — gives knowledge to game the system
403
+ if not is_write:
404
+ READ_PATTERNS = [
405
+ r"cat\s+",
406
+ r"less\s+",
407
+ r"more\s+",
408
+ r"head\s+",
409
+ r"tail\s+",
410
+ r"xxd\s+",
411
+ r"hexdump\s+",
412
+ r"strings\s+",
413
+ r"file\s+",
414
+ r"wc\s+",
415
+ r"md5sum\s+",
416
+ r"sha\d+sum\s+",
417
+ r"stat\s+",
418
+ r"cp\s+",
419
+ ]
420
+ for rp in READ_PATTERNS:
421
+ if re.search(rp + r".*" + re.escape(matched_pattern), command):
422
+ is_write = True # Treat as write for blocking purposes
423
+ matched_write = rp.split(r"\s")[0] + " (exfiltration)"
424
+ break
425
+
426
+ if not is_write:
427
+ # Benign reference — check if command needs sanitization before allowing
428
+ sanitized, mods = sanitize_command(command)
429
+ if mods:
430
+ allow_sanitized(sanitized, mods)
431
+ allow() # Truly benign reference to enforcement file name
432
+
433
+ # WRITE to protected file detected - check unlock flag
434
+ mgr = get_manager()
435
+ state = mgr.load()
436
+ unlock = state.get("enforcement_unlocked", {})
437
+
438
+ if unlock.get("active") and time.time() < unlock.get("expires_at", 0):
439
+ # Unlocked — still check if command needs sanitization
440
+ sanitized, mods = sanitize_command(command)
441
+ if mods:
442
+ allow_sanitized(sanitized, mods)
443
+ allow() # User unlocked enforcement - allow
444
+
445
+ # BLOCKED: Write to enforcement file without unlock
446
+ deny(
447
+ "BLOCKED: Bash command writes to enforcement file '"
448
+ + matched_pattern
449
+ + "' (via: '"
450
+ + str(matched_write)
451
+ + "'). Say 'unlock enforcement' to allow."
452
+ )
453
+
454
+
455
+ if __name__ == "__main__":
456
+ main()