opencode-block 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
- └── run-hook.cmd # Cross-platform entry point (Claude Code)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-block",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "File and directory protection for OpenCode using .block marker files with pattern matching",
5
5
  "main": "index.ts",
6
6
  "scripts": {
@@ -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
- 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)
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"],