opencode-block 1.1.14 → 1.2.1

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