opencode-block 1.2.0 → 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.
- package/README.md +32 -1
- package/package.json +1 -1
- package/protect_directories.py +183 -17
package/README.md
CHANGED
|
@@ -140,6 +140,35 @@ Keep Claude focused on specific directories during feature work:
|
|
|
140
140
|
}
|
|
141
141
|
```
|
|
142
142
|
|
|
143
|
+
### Agent-Specific Rules (Claude Code only)
|
|
144
|
+
|
|
145
|
+
Scope protection to specific subagent types. For example, block a code-review agent from modifying source files:
|
|
146
|
+
|
|
147
|
+
```text
|
|
148
|
+
src/
|
|
149
|
+
└── .block → {"agents": ["code-reviewer"]}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
This blocks the `code-reviewer` subagent from writing to `src/`. Other subagents and the main agent are unaffected — the `.block` file is skipped for them.
|
|
153
|
+
|
|
154
|
+
| Key | Type | Description |
|
|
155
|
+
|-----|------|-------------|
|
|
156
|
+
| `agents` | `string[]` | Subagent types this `.block` file applies to (others are skipped). Main agent is always skipped. |
|
|
157
|
+
| `disable_main_agent` | `bool` | When `true`, the main agent is skipped (for use without `agents`) |
|
|
158
|
+
|
|
159
|
+
**Truth table:**
|
|
160
|
+
|
|
161
|
+
*"Skipped" means this `.block` file is skipped — other `.block` files may still block.*
|
|
162
|
+
|
|
163
|
+
| Config | Main agent | Listed subagents | Other subagents |
|
|
164
|
+
|--------|-----------|-----------------|-----------------|
|
|
165
|
+
| No agent keys | Blocked | Blocked | Blocked |
|
|
166
|
+
| `agents: ["TestCreator"]` | Skipped | Blocked | Skipped |
|
|
167
|
+
| `disable_main_agent: true` | Skipped | Blocked | Blocked |
|
|
168
|
+
| Both keys set | Skipped | Blocked | Skipped |
|
|
169
|
+
| `agents: []` | Skipped | Skipped | Skipped |
|
|
170
|
+
|
|
171
|
+
|
|
143
172
|
## Pattern Syntax
|
|
144
173
|
|
|
145
174
|
| Pattern | Description |
|
|
@@ -231,7 +260,9 @@ pytest tests/ -v --cov=hooks --cov-report=term-missing
|
|
|
231
260
|
block/
|
|
232
261
|
├── hooks/
|
|
233
262
|
│ ├── protect_directories.py # Main protection logic (Python)
|
|
234
|
-
│
|
|
263
|
+
│ ├── subagent_tracker.py # Subagent event tracker (Claude Code)
|
|
264
|
+
│ ├── run-hook.cmd # Cross-platform entry point (Claude Code)
|
|
265
|
+
│ └── run-subagent-hook.cmd # Subagent hook entry point (Claude Code)
|
|
235
266
|
├── opencode/
|
|
236
267
|
│ ├── index.ts # OpenCode plugin entry point
|
|
237
268
|
│ └── package.json # npm package metadata
|
package/package.json
CHANGED
package/protect_directories.py
CHANGED
|
@@ -63,6 +63,10 @@ def _create_empty_config( # noqa: PLR0913
|
|
|
63
63
|
has_allowed_key: bool = False,
|
|
64
64
|
has_blocked_key: bool = False,
|
|
65
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,
|
|
66
70
|
) -> dict:
|
|
67
71
|
"""Create an empty config dict with optional overrides."""
|
|
68
72
|
return {
|
|
@@ -75,6 +79,10 @@ def _create_empty_config( # noqa: PLR0913
|
|
|
75
79
|
"has_allowed_key": has_allowed_key,
|
|
76
80
|
"has_blocked_key": has_blocked_key,
|
|
77
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,
|
|
78
86
|
}
|
|
79
87
|
|
|
80
88
|
|
|
@@ -209,9 +217,41 @@ def get_lock_file_config(marker_path: str) -> dict:
|
|
|
209
217
|
config["has_blocked_key"] = True
|
|
210
218
|
config["is_empty"] = False
|
|
211
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
|
+
|
|
212
232
|
return config
|
|
213
233
|
|
|
214
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
|
+
|
|
215
255
|
def merge_configs(main_config: dict, local_config: Optional[dict]) -> dict:
|
|
216
256
|
"""Merge two configs (main and local)."""
|
|
217
257
|
if not local_config:
|
|
@@ -222,6 +262,9 @@ def merge_configs(main_config: dict, local_config: Optional[dict]) -> dict:
|
|
|
222
262
|
if local_config.get("has_error"):
|
|
223
263
|
return local_config
|
|
224
264
|
|
|
265
|
+
# Local overrides main for agent fields
|
|
266
|
+
agent_fields = _merge_agent_fields(local_config, main_config)
|
|
267
|
+
|
|
225
268
|
main_empty = main_config.get("is_empty", True)
|
|
226
269
|
local_empty = local_config.get("is_empty", True)
|
|
227
270
|
|
|
@@ -230,7 +273,7 @@ def merge_configs(main_config: dict, local_config: Optional[dict]) -> dict:
|
|
|
230
273
|
main_guide = main_config.get("guide", "")
|
|
231
274
|
effective_guide = local_guide if local_guide else main_guide
|
|
232
275
|
|
|
233
|
-
return _create_empty_config(guide=effective_guide)
|
|
276
|
+
return _create_empty_config(guide=effective_guide, **agent_fields)
|
|
234
277
|
|
|
235
278
|
# Check if keys are present (not just if arrays have items)
|
|
236
279
|
main_has_allowed_key = main_config.get("has_allowed_key", False)
|
|
@@ -267,6 +310,7 @@ def merge_configs(main_config: dict, local_config: Optional[dict]) -> dict:
|
|
|
267
310
|
guide=merged_guide,
|
|
268
311
|
is_empty=False,
|
|
269
312
|
has_blocked_key=True,
|
|
313
|
+
**agent_fields,
|
|
270
314
|
)
|
|
271
315
|
|
|
272
316
|
if main_has_allowed_key or local_has_allowed_key:
|
|
@@ -280,9 +324,10 @@ def merge_configs(main_config: dict, local_config: Optional[dict]) -> dict:
|
|
|
280
324
|
guide=merged_guide,
|
|
281
325
|
is_empty=False,
|
|
282
326
|
has_allowed_key=True,
|
|
327
|
+
**agent_fields,
|
|
283
328
|
)
|
|
284
329
|
|
|
285
|
-
return _create_empty_config(guide=merged_guide)
|
|
330
|
+
return _create_empty_config(guide=merged_guide, **agent_fields)
|
|
286
331
|
|
|
287
332
|
|
|
288
333
|
def get_full_path(path: str) -> str:
|
|
@@ -300,6 +345,7 @@ def _merge_hierarchical_configs(child_config: dict, parent_config: dict) -> dict
|
|
|
300
345
|
- Blocked patterns are combined (union) from both levels
|
|
301
346
|
- Allowed patterns: child completely overrides parent (no inheritance)
|
|
302
347
|
- Guide: child guide takes precedence over parent guide
|
|
348
|
+
- Agent fields: child overrides parent (if child has the key)
|
|
303
349
|
"""
|
|
304
350
|
if not parent_config:
|
|
305
351
|
return child_config
|
|
@@ -312,6 +358,9 @@ def _merge_hierarchical_configs(child_config: dict, parent_config: dict) -> dict
|
|
|
312
358
|
if parent_config.get("has_error"):
|
|
313
359
|
return parent_config
|
|
314
360
|
|
|
361
|
+
# Child overrides parent for agent fields
|
|
362
|
+
agent_fields = _merge_agent_fields(child_config, parent_config)
|
|
363
|
+
|
|
315
364
|
child_empty = child_config.get("is_empty", True)
|
|
316
365
|
parent_empty = parent_config.get("is_empty", True)
|
|
317
366
|
|
|
@@ -321,7 +370,7 @@ def _merge_hierarchical_configs(child_config: dict, parent_config: dict) -> dict
|
|
|
321
370
|
|
|
322
371
|
# If child is empty (block all), it takes precedence over everything
|
|
323
372
|
if child_empty:
|
|
324
|
-
return _create_empty_config(guide=merged_guide)
|
|
373
|
+
return _create_empty_config(guide=merged_guide, **agent_fields)
|
|
325
374
|
|
|
326
375
|
# Child has specific patterns - check what modes are being used
|
|
327
376
|
child_has_allowed = child_config.get("has_allowed_key", False)
|
|
@@ -337,6 +386,7 @@ def _merge_hierarchical_configs(child_config: dict, parent_config: dict) -> dict
|
|
|
337
386
|
guide=merged_guide,
|
|
338
387
|
is_empty=False,
|
|
339
388
|
has_allowed_key=True,
|
|
389
|
+
**agent_fields,
|
|
340
390
|
)
|
|
341
391
|
|
|
342
392
|
# Child has blocked patterns - merge with parent's blocked patterns
|
|
@@ -351,6 +401,7 @@ def _merge_hierarchical_configs(child_config: dict, parent_config: dict) -> dict
|
|
|
351
401
|
guide=merged_guide,
|
|
352
402
|
is_empty=False,
|
|
353
403
|
has_blocked_key=True,
|
|
404
|
+
**agent_fields,
|
|
354
405
|
)
|
|
355
406
|
|
|
356
407
|
# Check for mode mixing
|
|
@@ -380,6 +431,7 @@ def _merge_hierarchical_configs(child_config: dict, parent_config: dict) -> dict
|
|
|
380
431
|
guide=merged_guide,
|
|
381
432
|
is_empty=False,
|
|
382
433
|
has_blocked_key=True,
|
|
434
|
+
**agent_fields,
|
|
383
435
|
)
|
|
384
436
|
|
|
385
437
|
# Parent has no blocked patterns, just use child's
|
|
@@ -388,6 +440,7 @@ def _merge_hierarchical_configs(child_config: dict, parent_config: dict) -> dict
|
|
|
388
440
|
guide=merged_guide,
|
|
389
441
|
is_empty=False,
|
|
390
442
|
has_blocked_key=True,
|
|
443
|
+
**agent_fields,
|
|
391
444
|
)
|
|
392
445
|
|
|
393
446
|
# Child has no patterns but is not empty (shouldn't happen, but handle gracefully)
|
|
@@ -398,6 +451,7 @@ def _merge_hierarchical_configs(child_config: dict, parent_config: dict) -> dict
|
|
|
398
451
|
guide=merged_guide,
|
|
399
452
|
is_empty=False,
|
|
400
453
|
has_allowed_key=True,
|
|
454
|
+
**agent_fields,
|
|
401
455
|
)
|
|
402
456
|
|
|
403
457
|
if parent_has_blocked:
|
|
@@ -406,9 +460,122 @@ def _merge_hierarchical_configs(child_config: dict, parent_config: dict) -> dict
|
|
|
406
460
|
guide=merged_guide,
|
|
407
461
|
is_empty=False,
|
|
408
462
|
has_blocked_key=True,
|
|
463
|
+
**agent_fields,
|
|
409
464
|
)
|
|
410
465
|
|
|
411
|
-
return _create_empty_config(guide=merged_guide)
|
|
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"])
|
|
412
579
|
|
|
413
580
|
|
|
414
581
|
def test_directory_protected(file_path: str) -> Optional[dict]:
|
|
@@ -907,6 +1074,9 @@ def main():
|
|
|
907
1074
|
else:
|
|
908
1075
|
sys.exit(0)
|
|
909
1076
|
|
|
1077
|
+
# Lazy agent resolution: resolved once when first needed, cached for all paths
|
|
1078
|
+
agent_state = {"resolved": False, "type": None}
|
|
1079
|
+
|
|
910
1080
|
for path in paths_to_check:
|
|
911
1081
|
if not path:
|
|
912
1082
|
continue
|
|
@@ -919,20 +1089,16 @@ def main():
|
|
|
919
1089
|
protection_info = test_directory_protected(path)
|
|
920
1090
|
|
|
921
1091
|
if protection_info:
|
|
1092
|
+
config = protection_info["config"]
|
|
922
1093
|
target_file = protection_info["target_file"]
|
|
923
1094
|
marker_path = protection_info["marker_path"]
|
|
924
1095
|
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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)
|
|
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"])
|
|
936
1102
|
|
|
937
1103
|
# Check if path targets a directory with its own or descendant .block files.
|
|
938
1104
|
# test_directory_protected() uses dirname() which may skip the target
|
|
@@ -942,7 +1108,7 @@ def main():
|
|
|
942
1108
|
if os.path.isdir(full_path):
|
|
943
1109
|
# Check the target directory itself for .block files.
|
|
944
1110
|
dir_info = get_merged_dir_config(full_path)
|
|
945
|
-
if dir_info:
|
|
1111
|
+
if dir_info and not _agent_exempt(dir_info["config"], data, agent_state):
|
|
946
1112
|
guide = dir_info["config"].get("guide", "")
|
|
947
1113
|
block_with_message(
|
|
948
1114
|
full_path, dir_info["marker_path"],
|
|
@@ -954,7 +1120,7 @@ def main():
|
|
|
954
1120
|
if descendant_marker:
|
|
955
1121
|
marker_dir = os.path.dirname(descendant_marker)
|
|
956
1122
|
desc_info = get_merged_dir_config(marker_dir)
|
|
957
|
-
if desc_info:
|
|
1123
|
+
if desc_info and not _agent_exempt(desc_info["config"], data, agent_state):
|
|
958
1124
|
guide = desc_info["config"].get("guide", "")
|
|
959
1125
|
block_with_message(
|
|
960
1126
|
full_path, desc_info["marker_path"],
|