opencode-block 1.1.14 → 1.2.0
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/README.md +260 -0
- package/index.ts +134 -134
- package/package.json +28 -28
- package/protect_directories.py +968 -968
package/protect_directories.py
CHANGED
|
@@ -1,968 +1,968 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
Claude Code Directory Protection Hook
|
|
4
|
-
|
|
5
|
-
Blocks file modifications when .block or .block.local exists in target directory or parent.
|
|
6
|
-
|
|
7
|
-
Configuration files:
|
|
8
|
-
.block - Main configuration file (committed to git)
|
|
9
|
-
.block.local - Local configuration file (not committed, add to .gitignore)
|
|
10
|
-
|
|
11
|
-
When both files exist in the same directory, they are merged:
|
|
12
|
-
- blocked patterns: combined (union - more restrictive)
|
|
13
|
-
- allowed patterns: local overrides main
|
|
14
|
-
- guide messages: local takes precedence
|
|
15
|
-
- Mixing allowed/blocked modes between files is an error
|
|
16
|
-
|
|
17
|
-
.block file format (JSON):
|
|
18
|
-
Empty file or {} = block everything
|
|
19
|
-
{ "allowed": ["pattern1", "pattern2"] } = only allow matching paths, block everything else
|
|
20
|
-
{ "blocked": ["pattern1", "pattern2"] } = only block matching paths, allow everything else
|
|
21
|
-
{ "guide": "message" } = common guide shown when blocked (fallback for patterns without specific guide)
|
|
22
|
-
Both allowed and blocked = error (invalid configuration)
|
|
23
|
-
|
|
24
|
-
Patterns can be strings or objects with per-pattern guides:
|
|
25
|
-
"pattern" = simple pattern (uses common guide as fallback)
|
|
26
|
-
{ "pattern": "...", "guide": "..." } = pattern with specific guide
|
|
27
|
-
|
|
28
|
-
Examples:
|
|
29
|
-
{ "blocked": ["*.secret", { "pattern": "config/**", "guide": "Config files protected." }] }
|
|
30
|
-
{ "allowed": ["docs/**", { "pattern": "src/gen/**", "guide": "Generated files." }], "guide": "Fallback" }
|
|
31
|
-
|
|
32
|
-
Guide priority: pattern-specific guide > common guide > default message
|
|
33
|
-
|
|
34
|
-
Patterns support wildcards:
|
|
35
|
-
* = any characters except path separator
|
|
36
|
-
** = any characters including path separator (recursive)
|
|
37
|
-
? = single character
|
|
38
|
-
"""
|
|
39
|
-
|
|
40
|
-
import json
|
|
41
|
-
import os
|
|
42
|
-
import re
|
|
43
|
-
import shlex
|
|
44
|
-
import sys
|
|
45
|
-
import warnings
|
|
46
|
-
from pathlib import Path
|
|
47
|
-
from typing import Optional, cast
|
|
48
|
-
|
|
49
|
-
# Regex special characters that need escaping
|
|
50
|
-
REGEX_SPECIAL_CHARS = ".^$[](){}+|\\"
|
|
51
|
-
|
|
52
|
-
MARKER_FILE_NAME = ".block"
|
|
53
|
-
LOCAL_MARKER_FILE_NAME = ".block.local"
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def _create_empty_config( # noqa: PLR0913
|
|
57
|
-
allowed: Optional[list] = None,
|
|
58
|
-
blocked: Optional[list] = None,
|
|
59
|
-
guide: str = "",
|
|
60
|
-
is_empty: bool = True,
|
|
61
|
-
has_error: bool = False,
|
|
62
|
-
error_message: str = "",
|
|
63
|
-
has_allowed_key: bool = False,
|
|
64
|
-
has_blocked_key: bool = False,
|
|
65
|
-
allow_all: bool = False,
|
|
66
|
-
) -> dict:
|
|
67
|
-
"""Create an empty config dict with optional overrides."""
|
|
68
|
-
return {
|
|
69
|
-
"allowed": allowed if allowed is not None else [],
|
|
70
|
-
"blocked": blocked if blocked is not None else [],
|
|
71
|
-
"guide": guide,
|
|
72
|
-
"is_empty": is_empty,
|
|
73
|
-
"has_error": has_error,
|
|
74
|
-
"error_message": error_message,
|
|
75
|
-
"has_allowed_key": has_allowed_key,
|
|
76
|
-
"has_blocked_key": has_blocked_key,
|
|
77
|
-
"allow_all": allow_all,
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def has_block_file_in_hierarchy(directory: str) -> bool:
|
|
82
|
-
"""Check if .block file exists in directory hierarchy (quick check)."""
|
|
83
|
-
directory = directory.replace("\\", "/")
|
|
84
|
-
path = Path(directory)
|
|
85
|
-
|
|
86
|
-
while path:
|
|
87
|
-
if (path / MARKER_FILE_NAME).exists() or (path / LOCAL_MARKER_FILE_NAME).exists():
|
|
88
|
-
return True
|
|
89
|
-
parent = path.parent
|
|
90
|
-
if parent == path:
|
|
91
|
-
break
|
|
92
|
-
path = parent
|
|
93
|
-
return False
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def extract_path_without_json(input_str: str) -> Optional[str]:
|
|
97
|
-
"""Extract file path from JSON without full parsing (fallback)."""
|
|
98
|
-
match = re.search(r'"(file_path|notebook_path)"\s*:\s*"([^"]*)"', input_str)
|
|
99
|
-
if match:
|
|
100
|
-
return match.group(2)
|
|
101
|
-
return None
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def convert_wildcard_to_regex(pattern: str) -> str:
|
|
105
|
-
"""Convert wildcard pattern to regex."""
|
|
106
|
-
pattern = pattern.replace("\\", "/")
|
|
107
|
-
result = []
|
|
108
|
-
i = 0
|
|
109
|
-
at_start = True
|
|
110
|
-
|
|
111
|
-
while i < len(pattern):
|
|
112
|
-
char = pattern[i]
|
|
113
|
-
next_char = pattern[i + 1] if i + 1 < len(pattern) else ""
|
|
114
|
-
next2_char = pattern[i + 2] if i + 2 < len(pattern) else ""
|
|
115
|
-
|
|
116
|
-
if char == "*":
|
|
117
|
-
if next_char == "*":
|
|
118
|
-
# **/ at start = optionally match any path + /
|
|
119
|
-
if at_start and next2_char == "/":
|
|
120
|
-
result.append("(.*/)?")
|
|
121
|
-
# Skip 2 extra chars (second * and /), loop adds 1 for first * = 3 total
|
|
122
|
-
i += 2
|
|
123
|
-
else:
|
|
124
|
-
result.append(".*")
|
|
125
|
-
# Skip 1 extra char (second *), loop adds 1 for first * = 2 total
|
|
126
|
-
i += 1
|
|
127
|
-
else:
|
|
128
|
-
result.append("[^/]*")
|
|
129
|
-
at_start = False
|
|
130
|
-
elif char == "?":
|
|
131
|
-
result.append(".")
|
|
132
|
-
at_start = False
|
|
133
|
-
elif char == "/":
|
|
134
|
-
result.append("/")
|
|
135
|
-
# After a /, we might have **/ again - don't reset at_start
|
|
136
|
-
elif char in REGEX_SPECIAL_CHARS:
|
|
137
|
-
result.append(f"\\{char}")
|
|
138
|
-
at_start = False
|
|
139
|
-
else:
|
|
140
|
-
result.append(char)
|
|
141
|
-
at_start = False
|
|
142
|
-
i += 1
|
|
143
|
-
|
|
144
|
-
return f"^{''.join(result)}$"
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
def test_path_matches_pattern(path: str, pattern: str, base_path: str) -> bool:
|
|
148
|
-
"""Test if path matches a pattern."""
|
|
149
|
-
path = path.replace("\\", "/")
|
|
150
|
-
base_path = base_path.replace("\\", "/").rstrip("/")
|
|
151
|
-
|
|
152
|
-
lower_path = path.lower()
|
|
153
|
-
lower_base = base_path.lower()
|
|
154
|
-
|
|
155
|
-
if lower_path.startswith(lower_base):
|
|
156
|
-
relative_path = path[len(base_path):].lstrip("/")
|
|
157
|
-
else:
|
|
158
|
-
relative_path = path
|
|
159
|
-
|
|
160
|
-
regex = convert_wildcard_to_regex(pattern)
|
|
161
|
-
|
|
162
|
-
try:
|
|
163
|
-
return bool(re.match(regex, relative_path))
|
|
164
|
-
except re.error as e:
|
|
165
|
-
warnings.warn(f"Invalid regex pattern '{pattern}' (converted: '{regex}'): {e}", stacklevel=2)
|
|
166
|
-
return False
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
def get_lock_file_config(marker_path: str) -> dict:
|
|
170
|
-
"""Get lock file configuration."""
|
|
171
|
-
config = _create_empty_config()
|
|
172
|
-
|
|
173
|
-
if not os.path.isfile(marker_path):
|
|
174
|
-
return config
|
|
175
|
-
|
|
176
|
-
try:
|
|
177
|
-
with open(marker_path, encoding="utf-8") as f:
|
|
178
|
-
content = f.read()
|
|
179
|
-
except OSError:
|
|
180
|
-
return config
|
|
181
|
-
|
|
182
|
-
if not content or content.isspace():
|
|
183
|
-
return config
|
|
184
|
-
|
|
185
|
-
try:
|
|
186
|
-
data = json.loads(content)
|
|
187
|
-
except json.JSONDecodeError:
|
|
188
|
-
return config
|
|
189
|
-
|
|
190
|
-
# Extract guide (applies to all modes)
|
|
191
|
-
config["guide"] = data.get("guide", "")
|
|
192
|
-
|
|
193
|
-
# Check for top-level allowed/blocked
|
|
194
|
-
has_allowed = "allowed" in data
|
|
195
|
-
has_blocked = "blocked" in data
|
|
196
|
-
|
|
197
|
-
if has_allowed and has_blocked:
|
|
198
|
-
config["has_error"] = True
|
|
199
|
-
config["error_message"] = "Invalid .block: cannot specify both allowed and blocked lists"
|
|
200
|
-
return config
|
|
201
|
-
|
|
202
|
-
if has_allowed:
|
|
203
|
-
config["allowed"] = data["allowed"]
|
|
204
|
-
config["has_allowed_key"] = True
|
|
205
|
-
config["is_empty"] = False
|
|
206
|
-
|
|
207
|
-
if has_blocked:
|
|
208
|
-
config["blocked"] = data["blocked"]
|
|
209
|
-
config["has_blocked_key"] = True
|
|
210
|
-
config["is_empty"] = False
|
|
211
|
-
|
|
212
|
-
return config
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
def merge_configs(main_config: dict, local_config: Optional[dict]) -> dict:
|
|
216
|
-
"""Merge two configs (main and local)."""
|
|
217
|
-
if not local_config:
|
|
218
|
-
return main_config
|
|
219
|
-
|
|
220
|
-
if main_config.get("has_error"):
|
|
221
|
-
return main_config
|
|
222
|
-
if local_config.get("has_error"):
|
|
223
|
-
return local_config
|
|
224
|
-
|
|
225
|
-
main_empty = main_config.get("is_empty", True)
|
|
226
|
-
local_empty = local_config.get("is_empty", True)
|
|
227
|
-
|
|
228
|
-
if main_empty or local_empty:
|
|
229
|
-
local_guide = local_config.get("guide", "")
|
|
230
|
-
main_guide = main_config.get("guide", "")
|
|
231
|
-
effective_guide = local_guide if local_guide else main_guide
|
|
232
|
-
|
|
233
|
-
return _create_empty_config(guide=effective_guide)
|
|
234
|
-
|
|
235
|
-
# Check if keys are present (not just if arrays have items)
|
|
236
|
-
main_has_allowed_key = main_config.get("has_allowed_key", False)
|
|
237
|
-
main_has_blocked_key = main_config.get("has_blocked_key", False)
|
|
238
|
-
local_has_allowed_key = local_config.get("has_allowed_key", False)
|
|
239
|
-
local_has_blocked_key = local_config.get("has_blocked_key", False)
|
|
240
|
-
|
|
241
|
-
# Check for mode mixing
|
|
242
|
-
if (main_has_allowed_key and local_has_blocked_key) or (main_has_blocked_key and local_has_allowed_key):
|
|
243
|
-
return _create_empty_config(
|
|
244
|
-
is_empty=False,
|
|
245
|
-
has_error=True,
|
|
246
|
-
error_message="Invalid configuration: .block and .block.local cannot mix allowed and blocked modes",
|
|
247
|
-
)
|
|
248
|
-
|
|
249
|
-
local_guide = local_config.get("guide", "")
|
|
250
|
-
main_guide = main_config.get("guide", "")
|
|
251
|
-
merged_guide = local_guide if local_guide else main_guide
|
|
252
|
-
|
|
253
|
-
if main_has_blocked_key or local_has_blocked_key:
|
|
254
|
-
main_blocked = main_config.get("blocked", [])
|
|
255
|
-
local_blocked = local_config.get("blocked", [])
|
|
256
|
-
merged_blocked = list(main_blocked) + list(local_blocked)
|
|
257
|
-
seen = set()
|
|
258
|
-
unique_blocked = []
|
|
259
|
-
for item in merged_blocked:
|
|
260
|
-
key = json.dumps(item, sort_keys=True) if isinstance(item, dict) else item
|
|
261
|
-
if key not in seen:
|
|
262
|
-
seen.add(key)
|
|
263
|
-
unique_blocked.append(item)
|
|
264
|
-
|
|
265
|
-
return _create_empty_config(
|
|
266
|
-
blocked=unique_blocked,
|
|
267
|
-
guide=merged_guide,
|
|
268
|
-
is_empty=False,
|
|
269
|
-
has_blocked_key=True,
|
|
270
|
-
)
|
|
271
|
-
|
|
272
|
-
if main_has_allowed_key or local_has_allowed_key:
|
|
273
|
-
if local_has_allowed_key:
|
|
274
|
-
merged_allowed = local_config.get("allowed", [])
|
|
275
|
-
else:
|
|
276
|
-
merged_allowed = main_config.get("allowed", [])
|
|
277
|
-
|
|
278
|
-
return _create_empty_config(
|
|
279
|
-
allowed=merged_allowed,
|
|
280
|
-
guide=merged_guide,
|
|
281
|
-
is_empty=False,
|
|
282
|
-
has_allowed_key=True,
|
|
283
|
-
)
|
|
284
|
-
|
|
285
|
-
return _create_empty_config(guide=merged_guide)
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
def get_full_path(path: str) -> str:
|
|
289
|
-
"""Get full/absolute path."""
|
|
290
|
-
if os.path.isabs(path) or (len(path) >= 2 and path[1] == ":"):
|
|
291
|
-
return path
|
|
292
|
-
return os.path.join(os.getcwd(), path)
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
def _merge_hierarchical_configs(child_config: dict, parent_config: dict) -> dict:
|
|
296
|
-
"""Merge child and parent configs from different directory levels.
|
|
297
|
-
|
|
298
|
-
Inheritance rules:
|
|
299
|
-
- Child .block with specific patterns overrides parent's "block all"
|
|
300
|
-
- Blocked patterns are combined (union) from both levels
|
|
301
|
-
- Allowed patterns: child completely overrides parent (no inheritance)
|
|
302
|
-
- Guide: child guide takes precedence over parent guide
|
|
303
|
-
"""
|
|
304
|
-
if not parent_config:
|
|
305
|
-
return child_config
|
|
306
|
-
if not child_config:
|
|
307
|
-
return parent_config
|
|
308
|
-
|
|
309
|
-
# If either has an error, propagate it
|
|
310
|
-
if child_config.get("has_error"):
|
|
311
|
-
return child_config
|
|
312
|
-
if parent_config.get("has_error"):
|
|
313
|
-
return parent_config
|
|
314
|
-
|
|
315
|
-
child_empty = child_config.get("is_empty", True)
|
|
316
|
-
parent_empty = parent_config.get("is_empty", True)
|
|
317
|
-
|
|
318
|
-
child_guide = child_config.get("guide", "")
|
|
319
|
-
parent_guide = parent_config.get("guide", "")
|
|
320
|
-
merged_guide = child_guide if child_guide else parent_guide
|
|
321
|
-
|
|
322
|
-
# If child is empty (block all), it takes precedence over everything
|
|
323
|
-
if child_empty:
|
|
324
|
-
return _create_empty_config(guide=merged_guide)
|
|
325
|
-
|
|
326
|
-
# Child has specific patterns - check what modes are being used
|
|
327
|
-
child_has_allowed = child_config.get("has_allowed_key", False)
|
|
328
|
-
child_has_blocked = child_config.get("has_blocked_key", False)
|
|
329
|
-
parent_has_allowed = parent_config.get("has_allowed_key", False)
|
|
330
|
-
parent_has_blocked = parent_config.get("has_blocked_key", False)
|
|
331
|
-
|
|
332
|
-
# If child has allowed patterns, it completely overrides parent
|
|
333
|
-
# (regardless of whether parent is empty or has blocked patterns)
|
|
334
|
-
if child_has_allowed:
|
|
335
|
-
return _create_empty_config(
|
|
336
|
-
allowed=child_config.get("allowed", []),
|
|
337
|
-
guide=merged_guide,
|
|
338
|
-
is_empty=False,
|
|
339
|
-
has_allowed_key=True,
|
|
340
|
-
)
|
|
341
|
-
|
|
342
|
-
# Child has blocked patterns - merge with parent's blocked patterns
|
|
343
|
-
if child_has_blocked:
|
|
344
|
-
child_blocked = child_config.get("blocked", [])
|
|
345
|
-
|
|
346
|
-
# If parent is empty (block all), just use child's patterns
|
|
347
|
-
# (child's patterns are more specific)
|
|
348
|
-
if parent_empty:
|
|
349
|
-
return _create_empty_config(
|
|
350
|
-
blocked=child_blocked,
|
|
351
|
-
guide=merged_guide,
|
|
352
|
-
is_empty=False,
|
|
353
|
-
has_blocked_key=True,
|
|
354
|
-
)
|
|
355
|
-
|
|
356
|
-
# Check for mode mixing
|
|
357
|
-
if parent_has_allowed:
|
|
358
|
-
return _create_empty_config(
|
|
359
|
-
is_empty=False,
|
|
360
|
-
has_error=True,
|
|
361
|
-
error_message="Invalid configuration: parent and child .block files cannot mix allowed and blocked modes",
|
|
362
|
-
)
|
|
363
|
-
|
|
364
|
-
# Both have blocked patterns - combine them (union)
|
|
365
|
-
if parent_has_blocked:
|
|
366
|
-
parent_blocked = parent_config.get("blocked", [])
|
|
367
|
-
merged_blocked = list(child_blocked) + list(parent_blocked)
|
|
368
|
-
|
|
369
|
-
# Deduplicate while preserving order
|
|
370
|
-
seen = set()
|
|
371
|
-
unique_blocked = []
|
|
372
|
-
for item in merged_blocked:
|
|
373
|
-
key = json.dumps(item, sort_keys=True) if isinstance(item, dict) else item
|
|
374
|
-
if key not in seen:
|
|
375
|
-
seen.add(key)
|
|
376
|
-
unique_blocked.append(item)
|
|
377
|
-
|
|
378
|
-
return _create_empty_config(
|
|
379
|
-
blocked=unique_blocked,
|
|
380
|
-
guide=merged_guide,
|
|
381
|
-
is_empty=False,
|
|
382
|
-
has_blocked_key=True,
|
|
383
|
-
)
|
|
384
|
-
|
|
385
|
-
# Parent has no blocked patterns, just use child's
|
|
386
|
-
return _create_empty_config(
|
|
387
|
-
blocked=child_blocked,
|
|
388
|
-
guide=merged_guide,
|
|
389
|
-
is_empty=False,
|
|
390
|
-
has_blocked_key=True,
|
|
391
|
-
)
|
|
392
|
-
|
|
393
|
-
# Child has no patterns but is not empty (shouldn't happen, but handle gracefully)
|
|
394
|
-
# Fall back to parent's config with merged guide
|
|
395
|
-
if parent_has_allowed:
|
|
396
|
-
return _create_empty_config(
|
|
397
|
-
allowed=parent_config.get("allowed", []),
|
|
398
|
-
guide=merged_guide,
|
|
399
|
-
is_empty=False,
|
|
400
|
-
has_allowed_key=True,
|
|
401
|
-
)
|
|
402
|
-
|
|
403
|
-
if parent_has_blocked:
|
|
404
|
-
return _create_empty_config(
|
|
405
|
-
blocked=parent_config.get("blocked", []),
|
|
406
|
-
guide=merged_guide,
|
|
407
|
-
is_empty=False,
|
|
408
|
-
has_blocked_key=True,
|
|
409
|
-
)
|
|
410
|
-
|
|
411
|
-
return _create_empty_config(guide=merged_guide)
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
def test_directory_protected(file_path: str) -> Optional[dict]:
|
|
415
|
-
"""Test if directory is protected, returns protection info or None.
|
|
416
|
-
|
|
417
|
-
Walks up the entire directory tree collecting all .block files,
|
|
418
|
-
then merges their configurations. Child configs inherit parent
|
|
419
|
-
blocked patterns (combined) but can override guides.
|
|
420
|
-
"""
|
|
421
|
-
if not file_path:
|
|
422
|
-
return None
|
|
423
|
-
|
|
424
|
-
file_path = get_full_path(file_path)
|
|
425
|
-
file_path = file_path.replace("\\", "/")
|
|
426
|
-
|
|
427
|
-
# Explicit path traversal check per best practices
|
|
428
|
-
# Block paths containing ".." components to prevent directory traversal attacks
|
|
429
|
-
if ".." in file_path.split("/"):
|
|
430
|
-
return None
|
|
431
|
-
|
|
432
|
-
directory = os.path.dirname(file_path)
|
|
433
|
-
|
|
434
|
-
if not directory:
|
|
435
|
-
return None
|
|
436
|
-
|
|
437
|
-
# Collect all configs from hierarchy (child to parent order)
|
|
438
|
-
configs_with_dirs = []
|
|
439
|
-
|
|
440
|
-
current_dir = directory
|
|
441
|
-
while current_dir:
|
|
442
|
-
marker_path = os.path.join(current_dir, MARKER_FILE_NAME)
|
|
443
|
-
local_marker_path = os.path.join(current_dir, LOCAL_MARKER_FILE_NAME)
|
|
444
|
-
has_main = os.path.isfile(marker_path)
|
|
445
|
-
has_local = os.path.isfile(local_marker_path)
|
|
446
|
-
|
|
447
|
-
if has_main or has_local:
|
|
448
|
-
if has_main:
|
|
449
|
-
main_config = get_lock_file_config(marker_path)
|
|
450
|
-
effective_marker_path = marker_path
|
|
451
|
-
else:
|
|
452
|
-
main_config = _create_empty_config()
|
|
453
|
-
effective_marker_path = None
|
|
454
|
-
|
|
455
|
-
if has_local:
|
|
456
|
-
local_config = get_lock_file_config(local_marker_path)
|
|
457
|
-
if not has_main:
|
|
458
|
-
effective_marker_path = local_marker_path
|
|
459
|
-
else:
|
|
460
|
-
effective_marker_path = f"{marker_path} (+ .local)"
|
|
461
|
-
else:
|
|
462
|
-
local_config = None
|
|
463
|
-
|
|
464
|
-
merged_config = merge_configs(main_config, local_config)
|
|
465
|
-
configs_with_dirs.append({
|
|
466
|
-
"config": merged_config,
|
|
467
|
-
"marker_path": effective_marker_path,
|
|
468
|
-
"marker_directory": current_dir,
|
|
469
|
-
})
|
|
470
|
-
|
|
471
|
-
parent = os.path.dirname(current_dir)
|
|
472
|
-
if parent == current_dir:
|
|
473
|
-
break
|
|
474
|
-
current_dir = parent
|
|
475
|
-
|
|
476
|
-
if not configs_with_dirs:
|
|
477
|
-
return None
|
|
478
|
-
|
|
479
|
-
# Merge all configs from child to parent
|
|
480
|
-
# Start with the closest (child) config and merge parents into it
|
|
481
|
-
final_config = cast("dict", configs_with_dirs[0]["config"])
|
|
482
|
-
closest_marker_path = cast("Optional[str]", configs_with_dirs[0]["marker_path"])
|
|
483
|
-
closest_marker_dir = cast("str", configs_with_dirs[0]["marker_directory"])
|
|
484
|
-
|
|
485
|
-
for i in range(1, len(configs_with_dirs)):
|
|
486
|
-
parent_config = cast("dict", configs_with_dirs[i]["config"])
|
|
487
|
-
final_config = _merge_hierarchical_configs(final_config, parent_config)
|
|
488
|
-
|
|
489
|
-
# Build marker path description if multiple .block files are involved
|
|
490
|
-
if len(configs_with_dirs) > 1:
|
|
491
|
-
marker_paths = [cast("str", c["marker_path"]) for c in configs_with_dirs if c["marker_path"]]
|
|
492
|
-
effective_marker_path = " + ".join(marker_paths)
|
|
493
|
-
else:
|
|
494
|
-
effective_marker_path = closest_marker_path
|
|
495
|
-
|
|
496
|
-
return {
|
|
497
|
-
"target_file": file_path,
|
|
498
|
-
"marker_path": effective_marker_path,
|
|
499
|
-
"marker_directory": closest_marker_dir,
|
|
500
|
-
"config": final_config
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
def get_bash_target_paths(command: str) -> list:
|
|
505
|
-
"""Extract target paths from bash commands.
|
|
506
|
-
|
|
507
|
-
Uses shlex for proper handling of quoted paths with spaces.
|
|
508
|
-
Falls back to regex-based extraction if shlex parsing fails.
|
|
509
|
-
"""
|
|
510
|
-
if not command:
|
|
511
|
-
return []
|
|
512
|
-
|
|
513
|
-
paths = []
|
|
514
|
-
|
|
515
|
-
# Try shlex-based extraction first for better quoted path handling
|
|
516
|
-
try:
|
|
517
|
-
tokens = shlex.split(command)
|
|
518
|
-
# Commands that take paths as arguments
|
|
519
|
-
single_path_cmds = {"touch", "mkdir", "rmdir", "tee"}
|
|
520
|
-
multi_path_cmds = {"rm", "mv", "cp"}
|
|
521
|
-
|
|
522
|
-
i = 0
|
|
523
|
-
while i < len(tokens):
|
|
524
|
-
token = tokens[i]
|
|
525
|
-
|
|
526
|
-
# Handle redirection (>)
|
|
527
|
-
if ">" in token and token != ">":
|
|
528
|
-
# Handle cases like ">file" or ">>file"
|
|
529
|
-
redirect_path = token.lstrip(">").strip()
|
|
530
|
-
if redirect_path and not redirect_path.startswith("-"):
|
|
531
|
-
paths.append(redirect_path)
|
|
532
|
-
i += 1
|
|
533
|
-
continue
|
|
534
|
-
|
|
535
|
-
if (token == ">" or token == ">>") and i + 1 < len(tokens):
|
|
536
|
-
# Next token is the file
|
|
537
|
-
path = tokens[i + 1]
|
|
538
|
-
if path and not path.startswith("-"):
|
|
539
|
-
paths.append(path)
|
|
540
|
-
i += 2
|
|
541
|
-
continue
|
|
542
|
-
|
|
543
|
-
# Handle of= for dd command
|
|
544
|
-
if token.startswith("of="):
|
|
545
|
-
path = token[3:]
|
|
546
|
-
if path and not path.startswith("-"):
|
|
547
|
-
paths.append(path)
|
|
548
|
-
i += 1
|
|
549
|
-
continue
|
|
550
|
-
|
|
551
|
-
if token in single_path_cmds:
|
|
552
|
-
# Collect all non-option arguments as paths
|
|
553
|
-
i += 1
|
|
554
|
-
while i < len(tokens):
|
|
555
|
-
arg = tokens[i]
|
|
556
|
-
if arg.startswith("-"):
|
|
557
|
-
i += 1
|
|
558
|
-
continue
|
|
559
|
-
if arg in ("|", ";", "&", "&&", "||", ">", ">>"):
|
|
560
|
-
break
|
|
561
|
-
paths.append(arg)
|
|
562
|
-
i += 1
|
|
563
|
-
continue
|
|
564
|
-
|
|
565
|
-
if token in multi_path_cmds:
|
|
566
|
-
# Collect all non-option arguments as paths
|
|
567
|
-
i += 1
|
|
568
|
-
while i < len(tokens):
|
|
569
|
-
arg = tokens[i]
|
|
570
|
-
if arg.startswith("-"):
|
|
571
|
-
i += 1
|
|
572
|
-
continue
|
|
573
|
-
if arg in ("|", ";", "&", "&&", "||", ">", ">>"):
|
|
574
|
-
break
|
|
575
|
-
paths.append(arg)
|
|
576
|
-
i += 1
|
|
577
|
-
continue
|
|
578
|
-
|
|
579
|
-
i += 1
|
|
580
|
-
|
|
581
|
-
except ValueError:
|
|
582
|
-
# shlex parsing failed (e.g., unmatched quotes), fall back to regex
|
|
583
|
-
pass
|
|
584
|
-
|
|
585
|
-
# Regex-based fallback for edge cases and additional coverage
|
|
586
|
-
patterns = [
|
|
587
|
-
(r'\brm\s+(?:-[rRfiv]+\s+)*"([^"]+)"', 1),
|
|
588
|
-
(r"\brm\s+(?:-[rRfiv]+\s+)*'([^']+)'", 1),
|
|
589
|
-
(r'\brm\s+(?:-[rRfiv]+\s+)*([^\s|;&]+)', 1),
|
|
590
|
-
(r'\btouch\s+"([^"]+)"', 1),
|
|
591
|
-
(r"\btouch\s+'([^']+)'", 1),
|
|
592
|
-
(r'\btouch\s+([^\s|;&]+)', 1),
|
|
593
|
-
(r'\bmkdir\s+(?:-p\s+)?"([^"]+)"', 1),
|
|
594
|
-
(r"\bmkdir\s+(?:-p\s+)?'([^']+)'", 1),
|
|
595
|
-
(r'\bmkdir\s+(?:-p\s+)?([^\s|;&]+)', 1),
|
|
596
|
-
(r'\brmdir\s+"([^"]+)"', 1),
|
|
597
|
-
(r"\brmdir\s+'([^']+)'", 1),
|
|
598
|
-
(r'\brmdir\s+([^\s|;&]+)', 1),
|
|
599
|
-
(r'>\s*"([^"]+)"', 1),
|
|
600
|
-
(r">\s*'([^']+)'", 1),
|
|
601
|
-
(r'>\s*([^\s|;&>]+)', 1),
|
|
602
|
-
(r'\btee\s+(?:-a\s+)?"([^"]+)"', 1),
|
|
603
|
-
(r"\btee\s+(?:-a\s+)?'([^']+)'", 1),
|
|
604
|
-
(r'\btee\s+(?:-a\s+)?([^\s|;&]+)', 1),
|
|
605
|
-
(r'\bof="([^"]+)"', 1),
|
|
606
|
-
(r"\bof='([^']+)'", 1),
|
|
607
|
-
(r'\bof=([^\s|;&]+)', 1),
|
|
608
|
-
]
|
|
609
|
-
|
|
610
|
-
for pattern, group in patterns:
|
|
611
|
-
for match in re.finditer(pattern, command):
|
|
612
|
-
path = match.group(group)
|
|
613
|
-
if path and not path.startswith("-"):
|
|
614
|
-
paths.append(path)
|
|
615
|
-
|
|
616
|
-
# Handle mv and cp with quoted paths
|
|
617
|
-
mv_patterns = [
|
|
618
|
-
r'\bmv\s+(?:-[fiv]+\s+)*"([^"]+)"\s+"([^"]+)"',
|
|
619
|
-
r"\bmv\s+(?:-[fiv]+\s+)*'([^']+)'\s+'([^']+)'",
|
|
620
|
-
r'\bmv\s+(?:-[fiv]+\s+)*([^\s|;&]+)\s+([^\s|;&]+)',
|
|
621
|
-
]
|
|
622
|
-
for pattern in mv_patterns:
|
|
623
|
-
mv_match = re.search(pattern, command)
|
|
624
|
-
if mv_match:
|
|
625
|
-
for g in [1, 2]:
|
|
626
|
-
path = mv_match.group(g)
|
|
627
|
-
if path and not path.startswith("-"):
|
|
628
|
-
paths.append(path)
|
|
629
|
-
break
|
|
630
|
-
|
|
631
|
-
cp_patterns = [
|
|
632
|
-
r'\bcp\s+(?:-[rRfiv]+\s+)*"([^"]+)"\s+"([^"]+)"',
|
|
633
|
-
r"\bcp\s+(?:-[rRfiv]+\s+)*'([^']+)'\s+'([^']+)'",
|
|
634
|
-
r'\bcp\s+(?:-[rRfiv]+\s+)*([^\s|;&]+)\s+([^\s|;&]+)',
|
|
635
|
-
]
|
|
636
|
-
for pattern in cp_patterns:
|
|
637
|
-
cp_match = re.search(pattern, command)
|
|
638
|
-
if cp_match:
|
|
639
|
-
for g in [1, 2]:
|
|
640
|
-
path = cp_match.group(g)
|
|
641
|
-
if path and not path.startswith("-"):
|
|
642
|
-
paths.append(path)
|
|
643
|
-
break
|
|
644
|
-
|
|
645
|
-
return list(set(paths))
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
def get_merged_dir_config(directory: str) -> Optional[dict]:
|
|
649
|
-
"""Read and merge .block and .block.local configs for a single directory.
|
|
650
|
-
|
|
651
|
-
Returns a dict with 'config', 'marker_path' keys, or None if
|
|
652
|
-
neither marker file exists. Mirrors the per-directory merging
|
|
653
|
-
logic in test_directory_protected().
|
|
654
|
-
"""
|
|
655
|
-
main_marker = os.path.join(directory, MARKER_FILE_NAME)
|
|
656
|
-
local_marker = os.path.join(directory, LOCAL_MARKER_FILE_NAME)
|
|
657
|
-
has_main = os.path.isfile(main_marker)
|
|
658
|
-
has_local = os.path.isfile(local_marker)
|
|
659
|
-
|
|
660
|
-
if not has_main and not has_local:
|
|
661
|
-
return None
|
|
662
|
-
|
|
663
|
-
main_config = (
|
|
664
|
-
get_lock_file_config(main_marker)
|
|
665
|
-
if has_main
|
|
666
|
-
else _create_empty_config()
|
|
667
|
-
)
|
|
668
|
-
local_config = (
|
|
669
|
-
get_lock_file_config(local_marker) if has_local else None
|
|
670
|
-
)
|
|
671
|
-
merged = merge_configs(main_config, local_config)
|
|
672
|
-
|
|
673
|
-
if has_main and has_local:
|
|
674
|
-
effective_path = f"{main_marker} (+ .local)"
|
|
675
|
-
elif has_main:
|
|
676
|
-
effective_path = main_marker
|
|
677
|
-
else:
|
|
678
|
-
effective_path = local_marker
|
|
679
|
-
|
|
680
|
-
return {"config": merged, "marker_path": effective_path}
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
def check_descendant_block_files(dir_path: str) -> Optional[str]:
|
|
684
|
-
"""Check if a directory contains .block files in any descendant directory.
|
|
685
|
-
|
|
686
|
-
When a command targets a parent directory (e.g., rm -rf parent/),
|
|
687
|
-
this scans child directories for .block or .block.local files to prevent
|
|
688
|
-
bypassing directory-level protections by operating on a parent directory.
|
|
689
|
-
|
|
690
|
-
Returns path to first .block file found, or None.
|
|
691
|
-
"""
|
|
692
|
-
dir_path = get_full_path(dir_path)
|
|
693
|
-
|
|
694
|
-
if not os.path.isdir(dir_path):
|
|
695
|
-
return None
|
|
696
|
-
|
|
697
|
-
normalized = os.path.normpath(dir_path)
|
|
698
|
-
|
|
699
|
-
def _walk_error(err: OSError) -> None:
|
|
700
|
-
warnings.warn(
|
|
701
|
-
f"check_descendant_block_files: cannot read "
|
|
702
|
-
f"'{err.filename}' under '{dir_path}': {err}",
|
|
703
|
-
stacklevel=2,
|
|
704
|
-
)
|
|
705
|
-
|
|
706
|
-
try:
|
|
707
|
-
for root, _dirs, files in os.walk(
|
|
708
|
-
dir_path, onerror=_walk_error,
|
|
709
|
-
):
|
|
710
|
-
if os.path.normpath(root) == normalized:
|
|
711
|
-
continue
|
|
712
|
-
if MARKER_FILE_NAME in files:
|
|
713
|
-
return os.path.join(root, MARKER_FILE_NAME)
|
|
714
|
-
if LOCAL_MARKER_FILE_NAME in files:
|
|
715
|
-
return os.path.join(root, LOCAL_MARKER_FILE_NAME)
|
|
716
|
-
except OSError as exc:
|
|
717
|
-
warnings.warn(
|
|
718
|
-
f"check_descendant_block_files: os.walk failed "
|
|
719
|
-
f"for '{dir_path}': {exc}",
|
|
720
|
-
stacklevel=2,
|
|
721
|
-
)
|
|
722
|
-
return None
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
def test_is_marker_file(file_path: str) -> bool:
|
|
726
|
-
"""Check if path is a marker file (main or local)."""
|
|
727
|
-
if not file_path:
|
|
728
|
-
return False
|
|
729
|
-
filename = os.path.basename(file_path)
|
|
730
|
-
return filename in (MARKER_FILE_NAME, LOCAL_MARKER_FILE_NAME)
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
def block_marker_removal(target_file: str) -> None:
|
|
734
|
-
"""Block marker file removal."""
|
|
735
|
-
filename = os.path.basename(target_file)
|
|
736
|
-
message = f"""BLOCKED: Cannot modify {filename}
|
|
737
|
-
|
|
738
|
-
Target file: {target_file}
|
|
739
|
-
|
|
740
|
-
The {filename} file is protected and cannot be modified or removed by Claude.
|
|
741
|
-
This is a safety mechanism to ensure directory protection remains in effect.
|
|
742
|
-
|
|
743
|
-
To remove protection, manually delete the file using your file manager or terminal."""
|
|
744
|
-
|
|
745
|
-
print(json.dumps({"decision": "block", "reason": message}))
|
|
746
|
-
sys.exit(0)
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
def block_config_error(marker_path: str, error_message: str) -> None:
|
|
750
|
-
"""Block config error."""
|
|
751
|
-
message = f"""BLOCKED: Invalid {MARKER_FILE_NAME} configuration
|
|
752
|
-
|
|
753
|
-
Marker file: {marker_path}
|
|
754
|
-
Error: {error_message}
|
|
755
|
-
|
|
756
|
-
Please fix the configuration file. Valid formats:
|
|
757
|
-
- Empty file or {{}} = block everything
|
|
758
|
-
- {{ "allowed": ["pattern"] }} = only allow matching paths
|
|
759
|
-
- {{ "blocked": ["pattern"] }} = only block matching paths"""
|
|
760
|
-
|
|
761
|
-
print(json.dumps({"decision": "block", "reason": message}))
|
|
762
|
-
sys.exit(0)
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
def block_with_message(target_file: str, marker_path: str, reason: str, guide: str) -> None:
|
|
766
|
-
"""Block with message."""
|
|
767
|
-
if guide:
|
|
768
|
-
message = guide
|
|
769
|
-
else:
|
|
770
|
-
message = f"BLOCKED by .block: {marker_path}"
|
|
771
|
-
|
|
772
|
-
print(json.dumps({"decision": "block", "reason": message}))
|
|
773
|
-
sys.exit(0)
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
def test_should_block(file_path: str, protection_info: dict) -> dict:
|
|
777
|
-
"""Test if operation should be blocked."""
|
|
778
|
-
config = protection_info["config"]
|
|
779
|
-
marker_dir = protection_info["marker_directory"]
|
|
780
|
-
guide = config.get("guide", "")
|
|
781
|
-
|
|
782
|
-
if config.get("has_error"):
|
|
783
|
-
return {
|
|
784
|
-
"should_block": True,
|
|
785
|
-
"reason": config.get("error_message", "Configuration error"),
|
|
786
|
-
"is_config_error": True,
|
|
787
|
-
"guide": ""
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
if config.get("is_empty"):
|
|
791
|
-
return {
|
|
792
|
-
"should_block": True,
|
|
793
|
-
"reason": "This directory tree is protected from Claude edits (full protection).",
|
|
794
|
-
"is_config_error": False,
|
|
795
|
-
"guide": guide
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
# Check for allow_all flag (empty blocked array means "allow everything")
|
|
799
|
-
if config.get("allow_all"):
|
|
800
|
-
return {
|
|
801
|
-
"should_block": False,
|
|
802
|
-
"reason": "",
|
|
803
|
-
"is_config_error": False,
|
|
804
|
-
"guide": ""
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
# Check if we're in allowed mode (allowed key was present in config)
|
|
808
|
-
has_allowed_key = config.get("has_allowed_key", False)
|
|
809
|
-
allowed_list = config.get("allowed", [])
|
|
810
|
-
if has_allowed_key:
|
|
811
|
-
for entry in allowed_list:
|
|
812
|
-
if isinstance(entry, str):
|
|
813
|
-
pattern = entry
|
|
814
|
-
else:
|
|
815
|
-
pattern = entry.get("pattern", "")
|
|
816
|
-
|
|
817
|
-
if test_path_matches_pattern(file_path, pattern, marker_dir):
|
|
818
|
-
return {
|
|
819
|
-
"should_block": False,
|
|
820
|
-
"reason": "",
|
|
821
|
-
"is_config_error": False,
|
|
822
|
-
"guide": ""
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
return {
|
|
826
|
-
"should_block": True,
|
|
827
|
-
"reason": "Path is not in the allowed list.",
|
|
828
|
-
"is_config_error": False,
|
|
829
|
-
"guide": guide
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
# Check if we're in blocked mode (blocked key was present in config)
|
|
833
|
-
has_blocked_key = config.get("has_blocked_key", False)
|
|
834
|
-
blocked_list = config.get("blocked", [])
|
|
835
|
-
if has_blocked_key:
|
|
836
|
-
for entry in blocked_list:
|
|
837
|
-
if isinstance(entry, str):
|
|
838
|
-
pattern = entry
|
|
839
|
-
entry_guide = ""
|
|
840
|
-
else:
|
|
841
|
-
pattern = entry.get("pattern", "")
|
|
842
|
-
entry_guide = entry.get("guide", "")
|
|
843
|
-
|
|
844
|
-
if test_path_matches_pattern(file_path, pattern, marker_dir):
|
|
845
|
-
effective_guide = entry_guide if entry_guide else guide
|
|
846
|
-
return {
|
|
847
|
-
"should_block": True,
|
|
848
|
-
"reason": f"Path matches blocked pattern: {pattern}",
|
|
849
|
-
"is_config_error": False,
|
|
850
|
-
"guide": effective_guide
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
# No pattern matched, allow (blocked mode with no matches = allow)
|
|
854
|
-
return {
|
|
855
|
-
"should_block": False,
|
|
856
|
-
"reason": "",
|
|
857
|
-
"is_config_error": False,
|
|
858
|
-
"guide": ""
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
return {
|
|
862
|
-
"should_block": True,
|
|
863
|
-
"reason": "This directory tree is protected from Claude edits.",
|
|
864
|
-
"is_config_error": False,
|
|
865
|
-
"guide": guide
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
def main():
|
|
870
|
-
"""Main entry point."""
|
|
871
|
-
hook_input = sys.stdin.read()
|
|
872
|
-
|
|
873
|
-
quick_path = extract_path_without_json(hook_input)
|
|
874
|
-
|
|
875
|
-
if quick_path:
|
|
876
|
-
quick_dir = os.path.dirname(quick_path)
|
|
877
|
-
if not os.path.isabs(quick_path) and not (len(quick_path) >= 2 and quick_path[1] == ":"):
|
|
878
|
-
quick_dir = os.path.join(os.getcwd(), quick_dir)
|
|
879
|
-
|
|
880
|
-
if not has_block_file_in_hierarchy(quick_dir):
|
|
881
|
-
sys.exit(0)
|
|
882
|
-
|
|
883
|
-
try:
|
|
884
|
-
data = json.loads(hook_input)
|
|
885
|
-
except json.JSONDecodeError:
|
|
886
|
-
sys.exit(0)
|
|
887
|
-
|
|
888
|
-
tool_name = data.get("tool_name", "")
|
|
889
|
-
if not tool_name:
|
|
890
|
-
sys.exit(0)
|
|
891
|
-
|
|
892
|
-
tool_input = data.get("tool_input", {})
|
|
893
|
-
paths_to_check = []
|
|
894
|
-
|
|
895
|
-
if tool_name == "Edit" or tool_name == "Write":
|
|
896
|
-
path = tool_input.get("file_path")
|
|
897
|
-
if path:
|
|
898
|
-
paths_to_check.append(path)
|
|
899
|
-
elif tool_name == "NotebookEdit":
|
|
900
|
-
path = tool_input.get("notebook_path")
|
|
901
|
-
if path:
|
|
902
|
-
paths_to_check.append(path)
|
|
903
|
-
elif tool_name == "Bash":
|
|
904
|
-
command = tool_input.get("command", "")
|
|
905
|
-
if command:
|
|
906
|
-
paths_to_check.extend(get_bash_target_paths(command))
|
|
907
|
-
else:
|
|
908
|
-
sys.exit(0)
|
|
909
|
-
|
|
910
|
-
for path in paths_to_check:
|
|
911
|
-
if not path:
|
|
912
|
-
continue
|
|
913
|
-
|
|
914
|
-
if test_is_marker_file(path):
|
|
915
|
-
full_path = get_full_path(path)
|
|
916
|
-
if os.path.isfile(full_path):
|
|
917
|
-
block_marker_removal(full_path)
|
|
918
|
-
|
|
919
|
-
protection_info = test_directory_protected(path)
|
|
920
|
-
|
|
921
|
-
if protection_info:
|
|
922
|
-
target_file = protection_info["target_file"]
|
|
923
|
-
marker_path = protection_info["marker_path"]
|
|
924
|
-
|
|
925
|
-
block_result = test_should_block(target_file, protection_info)
|
|
926
|
-
|
|
927
|
-
should_block = block_result["should_block"]
|
|
928
|
-
is_config_error = block_result["is_config_error"]
|
|
929
|
-
reason = block_result["reason"]
|
|
930
|
-
result_guide = block_result["guide"]
|
|
931
|
-
|
|
932
|
-
if is_config_error:
|
|
933
|
-
block_config_error(marker_path, reason)
|
|
934
|
-
elif should_block:
|
|
935
|
-
block_with_message(target_file, marker_path, reason, result_guide)
|
|
936
|
-
|
|
937
|
-
# Check if path targets a directory with its own or descendant .block files.
|
|
938
|
-
# test_directory_protected() uses dirname() which may skip the target
|
|
939
|
-
# directory itself when the path has no trailing slash. We handle both
|
|
940
|
-
# the target directory and its descendants explicitly here.
|
|
941
|
-
full_path = get_full_path(path)
|
|
942
|
-
if os.path.isdir(full_path):
|
|
943
|
-
# Check the target directory itself for .block files.
|
|
944
|
-
dir_info = get_merged_dir_config(full_path)
|
|
945
|
-
if dir_info:
|
|
946
|
-
guide = dir_info["config"].get("guide", "")
|
|
947
|
-
block_with_message(
|
|
948
|
-
full_path, dir_info["marker_path"],
|
|
949
|
-
"Directory is protected", guide,
|
|
950
|
-
)
|
|
951
|
-
|
|
952
|
-
# Check descendant directories for .block files.
|
|
953
|
-
descendant_marker = check_descendant_block_files(full_path)
|
|
954
|
-
if descendant_marker:
|
|
955
|
-
marker_dir = os.path.dirname(descendant_marker)
|
|
956
|
-
desc_info = get_merged_dir_config(marker_dir)
|
|
957
|
-
if desc_info:
|
|
958
|
-
guide = desc_info["config"].get("guide", "")
|
|
959
|
-
block_with_message(
|
|
960
|
-
full_path, desc_info["marker_path"],
|
|
961
|
-
"Child directory is protected", guide,
|
|
962
|
-
)
|
|
963
|
-
|
|
964
|
-
sys.exit(0)
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
if __name__ == "__main__":
|
|
968
|
-
main()
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Claude Code Directory Protection Hook
|
|
4
|
+
|
|
5
|
+
Blocks file modifications when .block or .block.local exists in target directory or parent.
|
|
6
|
+
|
|
7
|
+
Configuration files:
|
|
8
|
+
.block - Main configuration file (committed to git)
|
|
9
|
+
.block.local - Local configuration file (not committed, add to .gitignore)
|
|
10
|
+
|
|
11
|
+
When both files exist in the same directory, they are merged:
|
|
12
|
+
- blocked patterns: combined (union - more restrictive)
|
|
13
|
+
- allowed patterns: local overrides main
|
|
14
|
+
- guide messages: local takes precedence
|
|
15
|
+
- Mixing allowed/blocked modes between files is an error
|
|
16
|
+
|
|
17
|
+
.block file format (JSON):
|
|
18
|
+
Empty file or {} = block everything
|
|
19
|
+
{ "allowed": ["pattern1", "pattern2"] } = only allow matching paths, block everything else
|
|
20
|
+
{ "blocked": ["pattern1", "pattern2"] } = only block matching paths, allow everything else
|
|
21
|
+
{ "guide": "message" } = common guide shown when blocked (fallback for patterns without specific guide)
|
|
22
|
+
Both allowed and blocked = error (invalid configuration)
|
|
23
|
+
|
|
24
|
+
Patterns can be strings or objects with per-pattern guides:
|
|
25
|
+
"pattern" = simple pattern (uses common guide as fallback)
|
|
26
|
+
{ "pattern": "...", "guide": "..." } = pattern with specific guide
|
|
27
|
+
|
|
28
|
+
Examples:
|
|
29
|
+
{ "blocked": ["*.secret", { "pattern": "config/**", "guide": "Config files protected." }] }
|
|
30
|
+
{ "allowed": ["docs/**", { "pattern": "src/gen/**", "guide": "Generated files." }], "guide": "Fallback" }
|
|
31
|
+
|
|
32
|
+
Guide priority: pattern-specific guide > common guide > default message
|
|
33
|
+
|
|
34
|
+
Patterns support wildcards:
|
|
35
|
+
* = any characters except path separator
|
|
36
|
+
** = any characters including path separator (recursive)
|
|
37
|
+
? = single character
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
import json
|
|
41
|
+
import os
|
|
42
|
+
import re
|
|
43
|
+
import shlex
|
|
44
|
+
import sys
|
|
45
|
+
import warnings
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from typing import Optional, cast
|
|
48
|
+
|
|
49
|
+
# Regex special characters that need escaping
|
|
50
|
+
REGEX_SPECIAL_CHARS = ".^$[](){}+|\\"
|
|
51
|
+
|
|
52
|
+
MARKER_FILE_NAME = ".block"
|
|
53
|
+
LOCAL_MARKER_FILE_NAME = ".block.local"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _create_empty_config( # noqa: PLR0913
|
|
57
|
+
allowed: Optional[list] = None,
|
|
58
|
+
blocked: Optional[list] = None,
|
|
59
|
+
guide: str = "",
|
|
60
|
+
is_empty: bool = True,
|
|
61
|
+
has_error: bool = False,
|
|
62
|
+
error_message: str = "",
|
|
63
|
+
has_allowed_key: bool = False,
|
|
64
|
+
has_blocked_key: bool = False,
|
|
65
|
+
allow_all: bool = False,
|
|
66
|
+
) -> dict:
|
|
67
|
+
"""Create an empty config dict with optional overrides."""
|
|
68
|
+
return {
|
|
69
|
+
"allowed": allowed if allowed is not None else [],
|
|
70
|
+
"blocked": blocked if blocked is not None else [],
|
|
71
|
+
"guide": guide,
|
|
72
|
+
"is_empty": is_empty,
|
|
73
|
+
"has_error": has_error,
|
|
74
|
+
"error_message": error_message,
|
|
75
|
+
"has_allowed_key": has_allowed_key,
|
|
76
|
+
"has_blocked_key": has_blocked_key,
|
|
77
|
+
"allow_all": allow_all,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def has_block_file_in_hierarchy(directory: str) -> bool:
|
|
82
|
+
"""Check if .block file exists in directory hierarchy (quick check)."""
|
|
83
|
+
directory = directory.replace("\\", "/")
|
|
84
|
+
path = Path(directory)
|
|
85
|
+
|
|
86
|
+
while path:
|
|
87
|
+
if (path / MARKER_FILE_NAME).exists() or (path / LOCAL_MARKER_FILE_NAME).exists():
|
|
88
|
+
return True
|
|
89
|
+
parent = path.parent
|
|
90
|
+
if parent == path:
|
|
91
|
+
break
|
|
92
|
+
path = parent
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def extract_path_without_json(input_str: str) -> Optional[str]:
|
|
97
|
+
"""Extract file path from JSON without full parsing (fallback)."""
|
|
98
|
+
match = re.search(r'"(file_path|notebook_path)"\s*:\s*"([^"]*)"', input_str)
|
|
99
|
+
if match:
|
|
100
|
+
return match.group(2)
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def convert_wildcard_to_regex(pattern: str) -> str:
|
|
105
|
+
"""Convert wildcard pattern to regex."""
|
|
106
|
+
pattern = pattern.replace("\\", "/")
|
|
107
|
+
result = []
|
|
108
|
+
i = 0
|
|
109
|
+
at_start = True
|
|
110
|
+
|
|
111
|
+
while i < len(pattern):
|
|
112
|
+
char = pattern[i]
|
|
113
|
+
next_char = pattern[i + 1] if i + 1 < len(pattern) else ""
|
|
114
|
+
next2_char = pattern[i + 2] if i + 2 < len(pattern) else ""
|
|
115
|
+
|
|
116
|
+
if char == "*":
|
|
117
|
+
if next_char == "*":
|
|
118
|
+
# **/ at start = optionally match any path + /
|
|
119
|
+
if at_start and next2_char == "/":
|
|
120
|
+
result.append("(.*/)?")
|
|
121
|
+
# Skip 2 extra chars (second * and /), loop adds 1 for first * = 3 total
|
|
122
|
+
i += 2
|
|
123
|
+
else:
|
|
124
|
+
result.append(".*")
|
|
125
|
+
# Skip 1 extra char (second *), loop adds 1 for first * = 2 total
|
|
126
|
+
i += 1
|
|
127
|
+
else:
|
|
128
|
+
result.append("[^/]*")
|
|
129
|
+
at_start = False
|
|
130
|
+
elif char == "?":
|
|
131
|
+
result.append(".")
|
|
132
|
+
at_start = False
|
|
133
|
+
elif char == "/":
|
|
134
|
+
result.append("/")
|
|
135
|
+
# After a /, we might have **/ again - don't reset at_start
|
|
136
|
+
elif char in REGEX_SPECIAL_CHARS:
|
|
137
|
+
result.append(f"\\{char}")
|
|
138
|
+
at_start = False
|
|
139
|
+
else:
|
|
140
|
+
result.append(char)
|
|
141
|
+
at_start = False
|
|
142
|
+
i += 1
|
|
143
|
+
|
|
144
|
+
return f"^{''.join(result)}$"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_path_matches_pattern(path: str, pattern: str, base_path: str) -> bool:
|
|
148
|
+
"""Test if path matches a pattern."""
|
|
149
|
+
path = path.replace("\\", "/")
|
|
150
|
+
base_path = base_path.replace("\\", "/").rstrip("/")
|
|
151
|
+
|
|
152
|
+
lower_path = path.lower()
|
|
153
|
+
lower_base = base_path.lower()
|
|
154
|
+
|
|
155
|
+
if lower_path.startswith(lower_base):
|
|
156
|
+
relative_path = path[len(base_path):].lstrip("/")
|
|
157
|
+
else:
|
|
158
|
+
relative_path = path
|
|
159
|
+
|
|
160
|
+
regex = convert_wildcard_to_regex(pattern)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
return bool(re.match(regex, relative_path))
|
|
164
|
+
except re.error as e:
|
|
165
|
+
warnings.warn(f"Invalid regex pattern '{pattern}' (converted: '{regex}'): {e}", stacklevel=2)
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def get_lock_file_config(marker_path: str) -> dict:
|
|
170
|
+
"""Get lock file configuration."""
|
|
171
|
+
config = _create_empty_config()
|
|
172
|
+
|
|
173
|
+
if not os.path.isfile(marker_path):
|
|
174
|
+
return config
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
with open(marker_path, encoding="utf-8") as f:
|
|
178
|
+
content = f.read()
|
|
179
|
+
except OSError:
|
|
180
|
+
return config
|
|
181
|
+
|
|
182
|
+
if not content or content.isspace():
|
|
183
|
+
return config
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
data = json.loads(content)
|
|
187
|
+
except json.JSONDecodeError:
|
|
188
|
+
return config
|
|
189
|
+
|
|
190
|
+
# Extract guide (applies to all modes)
|
|
191
|
+
config["guide"] = data.get("guide", "")
|
|
192
|
+
|
|
193
|
+
# Check for top-level allowed/blocked
|
|
194
|
+
has_allowed = "allowed" in data
|
|
195
|
+
has_blocked = "blocked" in data
|
|
196
|
+
|
|
197
|
+
if has_allowed and has_blocked:
|
|
198
|
+
config["has_error"] = True
|
|
199
|
+
config["error_message"] = "Invalid .block: cannot specify both allowed and blocked lists"
|
|
200
|
+
return config
|
|
201
|
+
|
|
202
|
+
if has_allowed:
|
|
203
|
+
config["allowed"] = data["allowed"]
|
|
204
|
+
config["has_allowed_key"] = True
|
|
205
|
+
config["is_empty"] = False
|
|
206
|
+
|
|
207
|
+
if has_blocked:
|
|
208
|
+
config["blocked"] = data["blocked"]
|
|
209
|
+
config["has_blocked_key"] = True
|
|
210
|
+
config["is_empty"] = False
|
|
211
|
+
|
|
212
|
+
return config
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def merge_configs(main_config: dict, local_config: Optional[dict]) -> dict:
|
|
216
|
+
"""Merge two configs (main and local)."""
|
|
217
|
+
if not local_config:
|
|
218
|
+
return main_config
|
|
219
|
+
|
|
220
|
+
if main_config.get("has_error"):
|
|
221
|
+
return main_config
|
|
222
|
+
if local_config.get("has_error"):
|
|
223
|
+
return local_config
|
|
224
|
+
|
|
225
|
+
main_empty = main_config.get("is_empty", True)
|
|
226
|
+
local_empty = local_config.get("is_empty", True)
|
|
227
|
+
|
|
228
|
+
if main_empty or local_empty:
|
|
229
|
+
local_guide = local_config.get("guide", "")
|
|
230
|
+
main_guide = main_config.get("guide", "")
|
|
231
|
+
effective_guide = local_guide if local_guide else main_guide
|
|
232
|
+
|
|
233
|
+
return _create_empty_config(guide=effective_guide)
|
|
234
|
+
|
|
235
|
+
# Check if keys are present (not just if arrays have items)
|
|
236
|
+
main_has_allowed_key = main_config.get("has_allowed_key", False)
|
|
237
|
+
main_has_blocked_key = main_config.get("has_blocked_key", False)
|
|
238
|
+
local_has_allowed_key = local_config.get("has_allowed_key", False)
|
|
239
|
+
local_has_blocked_key = local_config.get("has_blocked_key", False)
|
|
240
|
+
|
|
241
|
+
# Check for mode mixing
|
|
242
|
+
if (main_has_allowed_key and local_has_blocked_key) or (main_has_blocked_key and local_has_allowed_key):
|
|
243
|
+
return _create_empty_config(
|
|
244
|
+
is_empty=False,
|
|
245
|
+
has_error=True,
|
|
246
|
+
error_message="Invalid configuration: .block and .block.local cannot mix allowed and blocked modes",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
local_guide = local_config.get("guide", "")
|
|
250
|
+
main_guide = main_config.get("guide", "")
|
|
251
|
+
merged_guide = local_guide if local_guide else main_guide
|
|
252
|
+
|
|
253
|
+
if main_has_blocked_key or local_has_blocked_key:
|
|
254
|
+
main_blocked = main_config.get("blocked", [])
|
|
255
|
+
local_blocked = local_config.get("blocked", [])
|
|
256
|
+
merged_blocked = list(main_blocked) + list(local_blocked)
|
|
257
|
+
seen = set()
|
|
258
|
+
unique_blocked = []
|
|
259
|
+
for item in merged_blocked:
|
|
260
|
+
key = json.dumps(item, sort_keys=True) if isinstance(item, dict) else item
|
|
261
|
+
if key not in seen:
|
|
262
|
+
seen.add(key)
|
|
263
|
+
unique_blocked.append(item)
|
|
264
|
+
|
|
265
|
+
return _create_empty_config(
|
|
266
|
+
blocked=unique_blocked,
|
|
267
|
+
guide=merged_guide,
|
|
268
|
+
is_empty=False,
|
|
269
|
+
has_blocked_key=True,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
if main_has_allowed_key or local_has_allowed_key:
|
|
273
|
+
if local_has_allowed_key:
|
|
274
|
+
merged_allowed = local_config.get("allowed", [])
|
|
275
|
+
else:
|
|
276
|
+
merged_allowed = main_config.get("allowed", [])
|
|
277
|
+
|
|
278
|
+
return _create_empty_config(
|
|
279
|
+
allowed=merged_allowed,
|
|
280
|
+
guide=merged_guide,
|
|
281
|
+
is_empty=False,
|
|
282
|
+
has_allowed_key=True,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
return _create_empty_config(guide=merged_guide)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def get_full_path(path: str) -> str:
|
|
289
|
+
"""Get full/absolute path."""
|
|
290
|
+
if os.path.isabs(path) or (len(path) >= 2 and path[1] == ":"):
|
|
291
|
+
return path
|
|
292
|
+
return os.path.join(os.getcwd(), path)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _merge_hierarchical_configs(child_config: dict, parent_config: dict) -> dict:
|
|
296
|
+
"""Merge child and parent configs from different directory levels.
|
|
297
|
+
|
|
298
|
+
Inheritance rules:
|
|
299
|
+
- Child .block with specific patterns overrides parent's "block all"
|
|
300
|
+
- Blocked patterns are combined (union) from both levels
|
|
301
|
+
- Allowed patterns: child completely overrides parent (no inheritance)
|
|
302
|
+
- Guide: child guide takes precedence over parent guide
|
|
303
|
+
"""
|
|
304
|
+
if not parent_config:
|
|
305
|
+
return child_config
|
|
306
|
+
if not child_config:
|
|
307
|
+
return parent_config
|
|
308
|
+
|
|
309
|
+
# If either has an error, propagate it
|
|
310
|
+
if child_config.get("has_error"):
|
|
311
|
+
return child_config
|
|
312
|
+
if parent_config.get("has_error"):
|
|
313
|
+
return parent_config
|
|
314
|
+
|
|
315
|
+
child_empty = child_config.get("is_empty", True)
|
|
316
|
+
parent_empty = parent_config.get("is_empty", True)
|
|
317
|
+
|
|
318
|
+
child_guide = child_config.get("guide", "")
|
|
319
|
+
parent_guide = parent_config.get("guide", "")
|
|
320
|
+
merged_guide = child_guide if child_guide else parent_guide
|
|
321
|
+
|
|
322
|
+
# If child is empty (block all), it takes precedence over everything
|
|
323
|
+
if child_empty:
|
|
324
|
+
return _create_empty_config(guide=merged_guide)
|
|
325
|
+
|
|
326
|
+
# Child has specific patterns - check what modes are being used
|
|
327
|
+
child_has_allowed = child_config.get("has_allowed_key", False)
|
|
328
|
+
child_has_blocked = child_config.get("has_blocked_key", False)
|
|
329
|
+
parent_has_allowed = parent_config.get("has_allowed_key", False)
|
|
330
|
+
parent_has_blocked = parent_config.get("has_blocked_key", False)
|
|
331
|
+
|
|
332
|
+
# If child has allowed patterns, it completely overrides parent
|
|
333
|
+
# (regardless of whether parent is empty or has blocked patterns)
|
|
334
|
+
if child_has_allowed:
|
|
335
|
+
return _create_empty_config(
|
|
336
|
+
allowed=child_config.get("allowed", []),
|
|
337
|
+
guide=merged_guide,
|
|
338
|
+
is_empty=False,
|
|
339
|
+
has_allowed_key=True,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# Child has blocked patterns - merge with parent's blocked patterns
|
|
343
|
+
if child_has_blocked:
|
|
344
|
+
child_blocked = child_config.get("blocked", [])
|
|
345
|
+
|
|
346
|
+
# If parent is empty (block all), just use child's patterns
|
|
347
|
+
# (child's patterns are more specific)
|
|
348
|
+
if parent_empty:
|
|
349
|
+
return _create_empty_config(
|
|
350
|
+
blocked=child_blocked,
|
|
351
|
+
guide=merged_guide,
|
|
352
|
+
is_empty=False,
|
|
353
|
+
has_blocked_key=True,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
# Check for mode mixing
|
|
357
|
+
if parent_has_allowed:
|
|
358
|
+
return _create_empty_config(
|
|
359
|
+
is_empty=False,
|
|
360
|
+
has_error=True,
|
|
361
|
+
error_message="Invalid configuration: parent and child .block files cannot mix allowed and blocked modes",
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
# Both have blocked patterns - combine them (union)
|
|
365
|
+
if parent_has_blocked:
|
|
366
|
+
parent_blocked = parent_config.get("blocked", [])
|
|
367
|
+
merged_blocked = list(child_blocked) + list(parent_blocked)
|
|
368
|
+
|
|
369
|
+
# Deduplicate while preserving order
|
|
370
|
+
seen = set()
|
|
371
|
+
unique_blocked = []
|
|
372
|
+
for item in merged_blocked:
|
|
373
|
+
key = json.dumps(item, sort_keys=True) if isinstance(item, dict) else item
|
|
374
|
+
if key not in seen:
|
|
375
|
+
seen.add(key)
|
|
376
|
+
unique_blocked.append(item)
|
|
377
|
+
|
|
378
|
+
return _create_empty_config(
|
|
379
|
+
blocked=unique_blocked,
|
|
380
|
+
guide=merged_guide,
|
|
381
|
+
is_empty=False,
|
|
382
|
+
has_blocked_key=True,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
# Parent has no blocked patterns, just use child's
|
|
386
|
+
return _create_empty_config(
|
|
387
|
+
blocked=child_blocked,
|
|
388
|
+
guide=merged_guide,
|
|
389
|
+
is_empty=False,
|
|
390
|
+
has_blocked_key=True,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
# Child has no patterns but is not empty (shouldn't happen, but handle gracefully)
|
|
394
|
+
# Fall back to parent's config with merged guide
|
|
395
|
+
if parent_has_allowed:
|
|
396
|
+
return _create_empty_config(
|
|
397
|
+
allowed=parent_config.get("allowed", []),
|
|
398
|
+
guide=merged_guide,
|
|
399
|
+
is_empty=False,
|
|
400
|
+
has_allowed_key=True,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
if parent_has_blocked:
|
|
404
|
+
return _create_empty_config(
|
|
405
|
+
blocked=parent_config.get("blocked", []),
|
|
406
|
+
guide=merged_guide,
|
|
407
|
+
is_empty=False,
|
|
408
|
+
has_blocked_key=True,
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
return _create_empty_config(guide=merged_guide)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def test_directory_protected(file_path: str) -> Optional[dict]:
|
|
415
|
+
"""Test if directory is protected, returns protection info or None.
|
|
416
|
+
|
|
417
|
+
Walks up the entire directory tree collecting all .block files,
|
|
418
|
+
then merges their configurations. Child configs inherit parent
|
|
419
|
+
blocked patterns (combined) but can override guides.
|
|
420
|
+
"""
|
|
421
|
+
if not file_path:
|
|
422
|
+
return None
|
|
423
|
+
|
|
424
|
+
file_path = get_full_path(file_path)
|
|
425
|
+
file_path = file_path.replace("\\", "/")
|
|
426
|
+
|
|
427
|
+
# Explicit path traversal check per best practices
|
|
428
|
+
# Block paths containing ".." components to prevent directory traversal attacks
|
|
429
|
+
if ".." in file_path.split("/"):
|
|
430
|
+
return None
|
|
431
|
+
|
|
432
|
+
directory = os.path.dirname(file_path)
|
|
433
|
+
|
|
434
|
+
if not directory:
|
|
435
|
+
return None
|
|
436
|
+
|
|
437
|
+
# Collect all configs from hierarchy (child to parent order)
|
|
438
|
+
configs_with_dirs = []
|
|
439
|
+
|
|
440
|
+
current_dir = directory
|
|
441
|
+
while current_dir:
|
|
442
|
+
marker_path = os.path.join(current_dir, MARKER_FILE_NAME)
|
|
443
|
+
local_marker_path = os.path.join(current_dir, LOCAL_MARKER_FILE_NAME)
|
|
444
|
+
has_main = os.path.isfile(marker_path)
|
|
445
|
+
has_local = os.path.isfile(local_marker_path)
|
|
446
|
+
|
|
447
|
+
if has_main or has_local:
|
|
448
|
+
if has_main:
|
|
449
|
+
main_config = get_lock_file_config(marker_path)
|
|
450
|
+
effective_marker_path = marker_path
|
|
451
|
+
else:
|
|
452
|
+
main_config = _create_empty_config()
|
|
453
|
+
effective_marker_path = None
|
|
454
|
+
|
|
455
|
+
if has_local:
|
|
456
|
+
local_config = get_lock_file_config(local_marker_path)
|
|
457
|
+
if not has_main:
|
|
458
|
+
effective_marker_path = local_marker_path
|
|
459
|
+
else:
|
|
460
|
+
effective_marker_path = f"{marker_path} (+ .local)"
|
|
461
|
+
else:
|
|
462
|
+
local_config = None
|
|
463
|
+
|
|
464
|
+
merged_config = merge_configs(main_config, local_config)
|
|
465
|
+
configs_with_dirs.append({
|
|
466
|
+
"config": merged_config,
|
|
467
|
+
"marker_path": effective_marker_path,
|
|
468
|
+
"marker_directory": current_dir,
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
parent = os.path.dirname(current_dir)
|
|
472
|
+
if parent == current_dir:
|
|
473
|
+
break
|
|
474
|
+
current_dir = parent
|
|
475
|
+
|
|
476
|
+
if not configs_with_dirs:
|
|
477
|
+
return None
|
|
478
|
+
|
|
479
|
+
# Merge all configs from child to parent
|
|
480
|
+
# Start with the closest (child) config and merge parents into it
|
|
481
|
+
final_config = cast("dict", configs_with_dirs[0]["config"])
|
|
482
|
+
closest_marker_path = cast("Optional[str]", configs_with_dirs[0]["marker_path"])
|
|
483
|
+
closest_marker_dir = cast("str", configs_with_dirs[0]["marker_directory"])
|
|
484
|
+
|
|
485
|
+
for i in range(1, len(configs_with_dirs)):
|
|
486
|
+
parent_config = cast("dict", configs_with_dirs[i]["config"])
|
|
487
|
+
final_config = _merge_hierarchical_configs(final_config, parent_config)
|
|
488
|
+
|
|
489
|
+
# Build marker path description if multiple .block files are involved
|
|
490
|
+
if len(configs_with_dirs) > 1:
|
|
491
|
+
marker_paths = [cast("str", c["marker_path"]) for c in configs_with_dirs if c["marker_path"]]
|
|
492
|
+
effective_marker_path = " + ".join(marker_paths)
|
|
493
|
+
else:
|
|
494
|
+
effective_marker_path = closest_marker_path
|
|
495
|
+
|
|
496
|
+
return {
|
|
497
|
+
"target_file": file_path,
|
|
498
|
+
"marker_path": effective_marker_path,
|
|
499
|
+
"marker_directory": closest_marker_dir,
|
|
500
|
+
"config": final_config
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def get_bash_target_paths(command: str) -> list:
|
|
505
|
+
"""Extract target paths from bash commands.
|
|
506
|
+
|
|
507
|
+
Uses shlex for proper handling of quoted paths with spaces.
|
|
508
|
+
Falls back to regex-based extraction if shlex parsing fails.
|
|
509
|
+
"""
|
|
510
|
+
if not command:
|
|
511
|
+
return []
|
|
512
|
+
|
|
513
|
+
paths = []
|
|
514
|
+
|
|
515
|
+
# Try shlex-based extraction first for better quoted path handling
|
|
516
|
+
try:
|
|
517
|
+
tokens = shlex.split(command)
|
|
518
|
+
# Commands that take paths as arguments
|
|
519
|
+
single_path_cmds = {"touch", "mkdir", "rmdir", "tee"}
|
|
520
|
+
multi_path_cmds = {"rm", "mv", "cp"}
|
|
521
|
+
|
|
522
|
+
i = 0
|
|
523
|
+
while i < len(tokens):
|
|
524
|
+
token = tokens[i]
|
|
525
|
+
|
|
526
|
+
# Handle redirection (>)
|
|
527
|
+
if ">" in token and token != ">":
|
|
528
|
+
# Handle cases like ">file" or ">>file"
|
|
529
|
+
redirect_path = token.lstrip(">").strip()
|
|
530
|
+
if redirect_path and not redirect_path.startswith("-"):
|
|
531
|
+
paths.append(redirect_path)
|
|
532
|
+
i += 1
|
|
533
|
+
continue
|
|
534
|
+
|
|
535
|
+
if (token == ">" or token == ">>") and i + 1 < len(tokens):
|
|
536
|
+
# Next token is the file
|
|
537
|
+
path = tokens[i + 1]
|
|
538
|
+
if path and not path.startswith("-"):
|
|
539
|
+
paths.append(path)
|
|
540
|
+
i += 2
|
|
541
|
+
continue
|
|
542
|
+
|
|
543
|
+
# Handle of= for dd command
|
|
544
|
+
if token.startswith("of="):
|
|
545
|
+
path = token[3:]
|
|
546
|
+
if path and not path.startswith("-"):
|
|
547
|
+
paths.append(path)
|
|
548
|
+
i += 1
|
|
549
|
+
continue
|
|
550
|
+
|
|
551
|
+
if token in single_path_cmds:
|
|
552
|
+
# Collect all non-option arguments as paths
|
|
553
|
+
i += 1
|
|
554
|
+
while i < len(tokens):
|
|
555
|
+
arg = tokens[i]
|
|
556
|
+
if arg.startswith("-"):
|
|
557
|
+
i += 1
|
|
558
|
+
continue
|
|
559
|
+
if arg in ("|", ";", "&", "&&", "||", ">", ">>"):
|
|
560
|
+
break
|
|
561
|
+
paths.append(arg)
|
|
562
|
+
i += 1
|
|
563
|
+
continue
|
|
564
|
+
|
|
565
|
+
if token in multi_path_cmds:
|
|
566
|
+
# Collect all non-option arguments as paths
|
|
567
|
+
i += 1
|
|
568
|
+
while i < len(tokens):
|
|
569
|
+
arg = tokens[i]
|
|
570
|
+
if arg.startswith("-"):
|
|
571
|
+
i += 1
|
|
572
|
+
continue
|
|
573
|
+
if arg in ("|", ";", "&", "&&", "||", ">", ">>"):
|
|
574
|
+
break
|
|
575
|
+
paths.append(arg)
|
|
576
|
+
i += 1
|
|
577
|
+
continue
|
|
578
|
+
|
|
579
|
+
i += 1
|
|
580
|
+
|
|
581
|
+
except ValueError:
|
|
582
|
+
# shlex parsing failed (e.g., unmatched quotes), fall back to regex
|
|
583
|
+
pass
|
|
584
|
+
|
|
585
|
+
# Regex-based fallback for edge cases and additional coverage
|
|
586
|
+
patterns = [
|
|
587
|
+
(r'\brm\s+(?:-[rRfiv]+\s+)*"([^"]+)"', 1),
|
|
588
|
+
(r"\brm\s+(?:-[rRfiv]+\s+)*'([^']+)'", 1),
|
|
589
|
+
(r'\brm\s+(?:-[rRfiv]+\s+)*([^\s|;&]+)', 1),
|
|
590
|
+
(r'\btouch\s+"([^"]+)"', 1),
|
|
591
|
+
(r"\btouch\s+'([^']+)'", 1),
|
|
592
|
+
(r'\btouch\s+([^\s|;&]+)', 1),
|
|
593
|
+
(r'\bmkdir\s+(?:-p\s+)?"([^"]+)"', 1),
|
|
594
|
+
(r"\bmkdir\s+(?:-p\s+)?'([^']+)'", 1),
|
|
595
|
+
(r'\bmkdir\s+(?:-p\s+)?([^\s|;&]+)', 1),
|
|
596
|
+
(r'\brmdir\s+"([^"]+)"', 1),
|
|
597
|
+
(r"\brmdir\s+'([^']+)'", 1),
|
|
598
|
+
(r'\brmdir\s+([^\s|;&]+)', 1),
|
|
599
|
+
(r'>\s*"([^"]+)"', 1),
|
|
600
|
+
(r">\s*'([^']+)'", 1),
|
|
601
|
+
(r'>\s*([^\s|;&>]+)', 1),
|
|
602
|
+
(r'\btee\s+(?:-a\s+)?"([^"]+)"', 1),
|
|
603
|
+
(r"\btee\s+(?:-a\s+)?'([^']+)'", 1),
|
|
604
|
+
(r'\btee\s+(?:-a\s+)?([^\s|;&]+)', 1),
|
|
605
|
+
(r'\bof="([^"]+)"', 1),
|
|
606
|
+
(r"\bof='([^']+)'", 1),
|
|
607
|
+
(r'\bof=([^\s|;&]+)', 1),
|
|
608
|
+
]
|
|
609
|
+
|
|
610
|
+
for pattern, group in patterns:
|
|
611
|
+
for match in re.finditer(pattern, command):
|
|
612
|
+
path = match.group(group)
|
|
613
|
+
if path and not path.startswith("-"):
|
|
614
|
+
paths.append(path)
|
|
615
|
+
|
|
616
|
+
# Handle mv and cp with quoted paths
|
|
617
|
+
mv_patterns = [
|
|
618
|
+
r'\bmv\s+(?:-[fiv]+\s+)*"([^"]+)"\s+"([^"]+)"',
|
|
619
|
+
r"\bmv\s+(?:-[fiv]+\s+)*'([^']+)'\s+'([^']+)'",
|
|
620
|
+
r'\bmv\s+(?:-[fiv]+\s+)*([^\s|;&]+)\s+([^\s|;&]+)',
|
|
621
|
+
]
|
|
622
|
+
for pattern in mv_patterns:
|
|
623
|
+
mv_match = re.search(pattern, command)
|
|
624
|
+
if mv_match:
|
|
625
|
+
for g in [1, 2]:
|
|
626
|
+
path = mv_match.group(g)
|
|
627
|
+
if path and not path.startswith("-"):
|
|
628
|
+
paths.append(path)
|
|
629
|
+
break
|
|
630
|
+
|
|
631
|
+
cp_patterns = [
|
|
632
|
+
r'\bcp\s+(?:-[rRfiv]+\s+)*"([^"]+)"\s+"([^"]+)"',
|
|
633
|
+
r"\bcp\s+(?:-[rRfiv]+\s+)*'([^']+)'\s+'([^']+)'",
|
|
634
|
+
r'\bcp\s+(?:-[rRfiv]+\s+)*([^\s|;&]+)\s+([^\s|;&]+)',
|
|
635
|
+
]
|
|
636
|
+
for pattern in cp_patterns:
|
|
637
|
+
cp_match = re.search(pattern, command)
|
|
638
|
+
if cp_match:
|
|
639
|
+
for g in [1, 2]:
|
|
640
|
+
path = cp_match.group(g)
|
|
641
|
+
if path and not path.startswith("-"):
|
|
642
|
+
paths.append(path)
|
|
643
|
+
break
|
|
644
|
+
|
|
645
|
+
return list(set(paths))
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def get_merged_dir_config(directory: str) -> Optional[dict]:
|
|
649
|
+
"""Read and merge .block and .block.local configs for a single directory.
|
|
650
|
+
|
|
651
|
+
Returns a dict with 'config', 'marker_path' keys, or None if
|
|
652
|
+
neither marker file exists. Mirrors the per-directory merging
|
|
653
|
+
logic in test_directory_protected().
|
|
654
|
+
"""
|
|
655
|
+
main_marker = os.path.join(directory, MARKER_FILE_NAME)
|
|
656
|
+
local_marker = os.path.join(directory, LOCAL_MARKER_FILE_NAME)
|
|
657
|
+
has_main = os.path.isfile(main_marker)
|
|
658
|
+
has_local = os.path.isfile(local_marker)
|
|
659
|
+
|
|
660
|
+
if not has_main and not has_local:
|
|
661
|
+
return None
|
|
662
|
+
|
|
663
|
+
main_config = (
|
|
664
|
+
get_lock_file_config(main_marker)
|
|
665
|
+
if has_main
|
|
666
|
+
else _create_empty_config()
|
|
667
|
+
)
|
|
668
|
+
local_config = (
|
|
669
|
+
get_lock_file_config(local_marker) if has_local else None
|
|
670
|
+
)
|
|
671
|
+
merged = merge_configs(main_config, local_config)
|
|
672
|
+
|
|
673
|
+
if has_main and has_local:
|
|
674
|
+
effective_path = f"{main_marker} (+ .local)"
|
|
675
|
+
elif has_main:
|
|
676
|
+
effective_path = main_marker
|
|
677
|
+
else:
|
|
678
|
+
effective_path = local_marker
|
|
679
|
+
|
|
680
|
+
return {"config": merged, "marker_path": effective_path}
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def check_descendant_block_files(dir_path: str) -> Optional[str]:
|
|
684
|
+
"""Check if a directory contains .block files in any descendant directory.
|
|
685
|
+
|
|
686
|
+
When a command targets a parent directory (e.g., rm -rf parent/),
|
|
687
|
+
this scans child directories for .block or .block.local files to prevent
|
|
688
|
+
bypassing directory-level protections by operating on a parent directory.
|
|
689
|
+
|
|
690
|
+
Returns path to first .block file found, or None.
|
|
691
|
+
"""
|
|
692
|
+
dir_path = get_full_path(dir_path)
|
|
693
|
+
|
|
694
|
+
if not os.path.isdir(dir_path):
|
|
695
|
+
return None
|
|
696
|
+
|
|
697
|
+
normalized = os.path.normpath(dir_path)
|
|
698
|
+
|
|
699
|
+
def _walk_error(err: OSError) -> None:
|
|
700
|
+
warnings.warn(
|
|
701
|
+
f"check_descendant_block_files: cannot read "
|
|
702
|
+
f"'{err.filename}' under '{dir_path}': {err}",
|
|
703
|
+
stacklevel=2,
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
try:
|
|
707
|
+
for root, _dirs, files in os.walk(
|
|
708
|
+
dir_path, onerror=_walk_error,
|
|
709
|
+
):
|
|
710
|
+
if os.path.normpath(root) == normalized:
|
|
711
|
+
continue
|
|
712
|
+
if MARKER_FILE_NAME in files:
|
|
713
|
+
return os.path.join(root, MARKER_FILE_NAME)
|
|
714
|
+
if LOCAL_MARKER_FILE_NAME in files:
|
|
715
|
+
return os.path.join(root, LOCAL_MARKER_FILE_NAME)
|
|
716
|
+
except OSError as exc:
|
|
717
|
+
warnings.warn(
|
|
718
|
+
f"check_descendant_block_files: os.walk failed "
|
|
719
|
+
f"for '{dir_path}': {exc}",
|
|
720
|
+
stacklevel=2,
|
|
721
|
+
)
|
|
722
|
+
return None
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def test_is_marker_file(file_path: str) -> bool:
|
|
726
|
+
"""Check if path is a marker file (main or local)."""
|
|
727
|
+
if not file_path:
|
|
728
|
+
return False
|
|
729
|
+
filename = os.path.basename(file_path)
|
|
730
|
+
return filename in (MARKER_FILE_NAME, LOCAL_MARKER_FILE_NAME)
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def block_marker_removal(target_file: str) -> None:
|
|
734
|
+
"""Block marker file removal."""
|
|
735
|
+
filename = os.path.basename(target_file)
|
|
736
|
+
message = f"""BLOCKED: Cannot modify {filename}
|
|
737
|
+
|
|
738
|
+
Target file: {target_file}
|
|
739
|
+
|
|
740
|
+
The {filename} file is protected and cannot be modified or removed by Claude.
|
|
741
|
+
This is a safety mechanism to ensure directory protection remains in effect.
|
|
742
|
+
|
|
743
|
+
To remove protection, manually delete the file using your file manager or terminal."""
|
|
744
|
+
|
|
745
|
+
print(json.dumps({"decision": "block", "reason": message}))
|
|
746
|
+
sys.exit(0)
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def block_config_error(marker_path: str, error_message: str) -> None:
|
|
750
|
+
"""Block config error."""
|
|
751
|
+
message = f"""BLOCKED: Invalid {MARKER_FILE_NAME} configuration
|
|
752
|
+
|
|
753
|
+
Marker file: {marker_path}
|
|
754
|
+
Error: {error_message}
|
|
755
|
+
|
|
756
|
+
Please fix the configuration file. Valid formats:
|
|
757
|
+
- Empty file or {{}} = block everything
|
|
758
|
+
- {{ "allowed": ["pattern"] }} = only allow matching paths
|
|
759
|
+
- {{ "blocked": ["pattern"] }} = only block matching paths"""
|
|
760
|
+
|
|
761
|
+
print(json.dumps({"decision": "block", "reason": message}))
|
|
762
|
+
sys.exit(0)
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def block_with_message(target_file: str, marker_path: str, reason: str, guide: str) -> None:
|
|
766
|
+
"""Block with message."""
|
|
767
|
+
if guide:
|
|
768
|
+
message = guide
|
|
769
|
+
else:
|
|
770
|
+
message = f"BLOCKED by .block: {marker_path}"
|
|
771
|
+
|
|
772
|
+
print(json.dumps({"decision": "block", "reason": message}))
|
|
773
|
+
sys.exit(0)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def test_should_block(file_path: str, protection_info: dict) -> dict:
|
|
777
|
+
"""Test if operation should be blocked."""
|
|
778
|
+
config = protection_info["config"]
|
|
779
|
+
marker_dir = protection_info["marker_directory"]
|
|
780
|
+
guide = config.get("guide", "")
|
|
781
|
+
|
|
782
|
+
if config.get("has_error"):
|
|
783
|
+
return {
|
|
784
|
+
"should_block": True,
|
|
785
|
+
"reason": config.get("error_message", "Configuration error"),
|
|
786
|
+
"is_config_error": True,
|
|
787
|
+
"guide": ""
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if config.get("is_empty"):
|
|
791
|
+
return {
|
|
792
|
+
"should_block": True,
|
|
793
|
+
"reason": "This directory tree is protected from Claude edits (full protection).",
|
|
794
|
+
"is_config_error": False,
|
|
795
|
+
"guide": guide
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
# Check for allow_all flag (empty blocked array means "allow everything")
|
|
799
|
+
if config.get("allow_all"):
|
|
800
|
+
return {
|
|
801
|
+
"should_block": False,
|
|
802
|
+
"reason": "",
|
|
803
|
+
"is_config_error": False,
|
|
804
|
+
"guide": ""
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
# Check if we're in allowed mode (allowed key was present in config)
|
|
808
|
+
has_allowed_key = config.get("has_allowed_key", False)
|
|
809
|
+
allowed_list = config.get("allowed", [])
|
|
810
|
+
if has_allowed_key:
|
|
811
|
+
for entry in allowed_list:
|
|
812
|
+
if isinstance(entry, str):
|
|
813
|
+
pattern = entry
|
|
814
|
+
else:
|
|
815
|
+
pattern = entry.get("pattern", "")
|
|
816
|
+
|
|
817
|
+
if test_path_matches_pattern(file_path, pattern, marker_dir):
|
|
818
|
+
return {
|
|
819
|
+
"should_block": False,
|
|
820
|
+
"reason": "",
|
|
821
|
+
"is_config_error": False,
|
|
822
|
+
"guide": ""
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
return {
|
|
826
|
+
"should_block": True,
|
|
827
|
+
"reason": "Path is not in the allowed list.",
|
|
828
|
+
"is_config_error": False,
|
|
829
|
+
"guide": guide
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
# Check if we're in blocked mode (blocked key was present in config)
|
|
833
|
+
has_blocked_key = config.get("has_blocked_key", False)
|
|
834
|
+
blocked_list = config.get("blocked", [])
|
|
835
|
+
if has_blocked_key:
|
|
836
|
+
for entry in blocked_list:
|
|
837
|
+
if isinstance(entry, str):
|
|
838
|
+
pattern = entry
|
|
839
|
+
entry_guide = ""
|
|
840
|
+
else:
|
|
841
|
+
pattern = entry.get("pattern", "")
|
|
842
|
+
entry_guide = entry.get("guide", "")
|
|
843
|
+
|
|
844
|
+
if test_path_matches_pattern(file_path, pattern, marker_dir):
|
|
845
|
+
effective_guide = entry_guide if entry_guide else guide
|
|
846
|
+
return {
|
|
847
|
+
"should_block": True,
|
|
848
|
+
"reason": f"Path matches blocked pattern: {pattern}",
|
|
849
|
+
"is_config_error": False,
|
|
850
|
+
"guide": effective_guide
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
# No pattern matched, allow (blocked mode with no matches = allow)
|
|
854
|
+
return {
|
|
855
|
+
"should_block": False,
|
|
856
|
+
"reason": "",
|
|
857
|
+
"is_config_error": False,
|
|
858
|
+
"guide": ""
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
return {
|
|
862
|
+
"should_block": True,
|
|
863
|
+
"reason": "This directory tree is protected from Claude edits.",
|
|
864
|
+
"is_config_error": False,
|
|
865
|
+
"guide": guide
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def main():
|
|
870
|
+
"""Main entry point."""
|
|
871
|
+
hook_input = sys.stdin.read()
|
|
872
|
+
|
|
873
|
+
quick_path = extract_path_without_json(hook_input)
|
|
874
|
+
|
|
875
|
+
if quick_path:
|
|
876
|
+
quick_dir = os.path.dirname(quick_path)
|
|
877
|
+
if not os.path.isabs(quick_path) and not (len(quick_path) >= 2 and quick_path[1] == ":"):
|
|
878
|
+
quick_dir = os.path.join(os.getcwd(), quick_dir)
|
|
879
|
+
|
|
880
|
+
if not has_block_file_in_hierarchy(quick_dir):
|
|
881
|
+
sys.exit(0)
|
|
882
|
+
|
|
883
|
+
try:
|
|
884
|
+
data = json.loads(hook_input)
|
|
885
|
+
except json.JSONDecodeError:
|
|
886
|
+
sys.exit(0)
|
|
887
|
+
|
|
888
|
+
tool_name = data.get("tool_name", "")
|
|
889
|
+
if not tool_name:
|
|
890
|
+
sys.exit(0)
|
|
891
|
+
|
|
892
|
+
tool_input = data.get("tool_input", {})
|
|
893
|
+
paths_to_check = []
|
|
894
|
+
|
|
895
|
+
if tool_name == "Edit" or tool_name == "Write":
|
|
896
|
+
path = tool_input.get("file_path")
|
|
897
|
+
if path:
|
|
898
|
+
paths_to_check.append(path)
|
|
899
|
+
elif tool_name == "NotebookEdit":
|
|
900
|
+
path = tool_input.get("notebook_path")
|
|
901
|
+
if path:
|
|
902
|
+
paths_to_check.append(path)
|
|
903
|
+
elif tool_name == "Bash":
|
|
904
|
+
command = tool_input.get("command", "")
|
|
905
|
+
if command:
|
|
906
|
+
paths_to_check.extend(get_bash_target_paths(command))
|
|
907
|
+
else:
|
|
908
|
+
sys.exit(0)
|
|
909
|
+
|
|
910
|
+
for path in paths_to_check:
|
|
911
|
+
if not path:
|
|
912
|
+
continue
|
|
913
|
+
|
|
914
|
+
if test_is_marker_file(path):
|
|
915
|
+
full_path = get_full_path(path)
|
|
916
|
+
if os.path.isfile(full_path):
|
|
917
|
+
block_marker_removal(full_path)
|
|
918
|
+
|
|
919
|
+
protection_info = test_directory_protected(path)
|
|
920
|
+
|
|
921
|
+
if protection_info:
|
|
922
|
+
target_file = protection_info["target_file"]
|
|
923
|
+
marker_path = protection_info["marker_path"]
|
|
924
|
+
|
|
925
|
+
block_result = test_should_block(target_file, protection_info)
|
|
926
|
+
|
|
927
|
+
should_block = block_result["should_block"]
|
|
928
|
+
is_config_error = block_result["is_config_error"]
|
|
929
|
+
reason = block_result["reason"]
|
|
930
|
+
result_guide = block_result["guide"]
|
|
931
|
+
|
|
932
|
+
if is_config_error:
|
|
933
|
+
block_config_error(marker_path, reason)
|
|
934
|
+
elif should_block:
|
|
935
|
+
block_with_message(target_file, marker_path, reason, result_guide)
|
|
936
|
+
|
|
937
|
+
# Check if path targets a directory with its own or descendant .block files.
|
|
938
|
+
# test_directory_protected() uses dirname() which may skip the target
|
|
939
|
+
# directory itself when the path has no trailing slash. We handle both
|
|
940
|
+
# the target directory and its descendants explicitly here.
|
|
941
|
+
full_path = get_full_path(path)
|
|
942
|
+
if os.path.isdir(full_path):
|
|
943
|
+
# Check the target directory itself for .block files.
|
|
944
|
+
dir_info = get_merged_dir_config(full_path)
|
|
945
|
+
if dir_info:
|
|
946
|
+
guide = dir_info["config"].get("guide", "")
|
|
947
|
+
block_with_message(
|
|
948
|
+
full_path, dir_info["marker_path"],
|
|
949
|
+
"Directory is protected", guide,
|
|
950
|
+
)
|
|
951
|
+
|
|
952
|
+
# Check descendant directories for .block files.
|
|
953
|
+
descendant_marker = check_descendant_block_files(full_path)
|
|
954
|
+
if descendant_marker:
|
|
955
|
+
marker_dir = os.path.dirname(descendant_marker)
|
|
956
|
+
desc_info = get_merged_dir_config(marker_dir)
|
|
957
|
+
if desc_info:
|
|
958
|
+
guide = desc_info["config"].get("guide", "")
|
|
959
|
+
block_with_message(
|
|
960
|
+
full_path, desc_info["marker_path"],
|
|
961
|
+
"Child directory is protected", guide,
|
|
962
|
+
)
|
|
963
|
+
|
|
964
|
+
sys.exit(0)
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
if __name__ == "__main__":
|
|
968
|
+
main()
|