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.
Files changed (4) hide show
  1. package/README.md +260 -0
  2. package/index.ts +134 -134
  3. package/package.json +28 -28
  4. package/protect_directories.py +968 -968
@@ -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()