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.
- cli_enforcement/__init__.py +10 -0
- cli_enforcement/cli.py +58 -0
- cli_enforcement/deploy.py +393 -0
- cli_enforcement/engine/__init__.py +0 -0
- cli_enforcement/engine/achievements.py +215 -0
- cli_enforcement/engine/bash_gate.py +456 -0
- cli_enforcement/engine/cascade_manager.py +745 -0
- cli_enforcement/engine/check_dismissal.py +182 -0
- cli_enforcement/engine/config_change.py +104 -0
- cli_enforcement/engine/enforce_check.py +645 -0
- cli_enforcement/engine/enforcement_cli.py +470 -0
- cli_enforcement/engine/enforcement_logger.py +209 -0
- cli_enforcement/engine/enforcement_unlock.py +171 -0
- cli_enforcement/engine/flush_reads.py +56 -0
- cli_enforcement/engine/instructions_loaded.py +92 -0
- cli_enforcement/engine/integrity_check.py +293 -0
- cli_enforcement/engine/message_generator.py +334 -0
- cli_enforcement/engine/message_generator_v2.py +150 -0
- cli_enforcement/engine/permission_check.py +137 -0
- cli_enforcement/engine/points_config.yaml +40 -0
- cli_enforcement/engine/post_bash_check.py +236 -0
- cli_enforcement/engine/post_compact.py +110 -0
- cli_enforcement/engine/post_edit_validate.py +543 -0
- cli_enforcement/engine/pre_compact.py +83 -0
- cli_enforcement/engine/project_integrity.py +217 -0
- cli_enforcement/engine/record_edit.py +205 -0
- cli_enforcement/engine/record_read.py +403 -0
- cli_enforcement/engine/session_end.py +278 -0
- cli_enforcement/engine/session_init.py +238 -0
- cli_enforcement/engine/state_manager.py +2250 -0
- cli_enforcement/engine/stop_check.py +233 -0
- cli_enforcement/engine/stop_failure.py +57 -0
- cli_enforcement/engine/subagent_start.py +90 -0
- cli_enforcement/engine/subagent_stop.py +106 -0
- cli_enforcement/engine/task_completed.py +53 -0
- cli_enforcement/engine/tool_failure.py +97 -0
- cli_enforcement/engine/workflow_server.py +1820 -0
- cli_enforcement/fleet.py +125 -0
- cli_enforcement-0.1.0.dist-info/METADATA +76 -0
- cli_enforcement-0.1.0.dist-info/RECORD +43 -0
- cli_enforcement-0.1.0.dist-info/WHEEL +4 -0
- cli_enforcement-0.1.0.dist-info/entry_points.txt +2 -0
- 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()
|