@heretek-ai/epistemic-swarm 0.7.21 → 0.7.23

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 (104) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +1 -1
  3. package/bin/cli.js +3 -2
  4. package/extensions/pi/index.js +3 -2
  5. package/package.json +5 -1
  6. package/plugins/antigravity/plugin.json +1 -1
  7. package/plugins/factory/skills/factory/SKILL.md +1 -1
  8. package/plugins/opencode/analysis-bridge.js +566 -0
  9. package/plugins/opencode/config-io.js +598 -31
  10. package/plugins/opencode/dep-health.js +1039 -0
  11. package/plugins/opencode/hook-bus.js +434 -0
  12. package/plugins/opencode/index.js +252 -19
  13. package/plugins/opencode/tui.js +230 -17
  14. package/plugins/opencode/weight-baseline.json +16 -0
  15. package/plugins/opencode/weight-signals.js +769 -0
  16. package/plugins/research-cache/skills/research_cache/hasher.py +33 -8
  17. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  18. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  19. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  20. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  21. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  22. package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
  23. package/runner/__pycache__/errors.cpython-311.pyc +0 -0
  24. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  25. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  26. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  27. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  28. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  29. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  30. package/runner/__pycache__/preflight.cpython-311.pyc +0 -0
  31. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  32. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  33. package/runner/__pycache__/retrieval_log.cpython-311.pyc +0 -0
  34. package/runner/__pycache__/schema_validate.cpython-311.pyc +0 -0
  35. package/runner/__pycache__/schemas.cpython-311.pyc +0 -0
  36. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  37. package/runner/mcp_server.py +249 -81
  38. package/runner/refinement.py +5 -0
  39. package/runner/schema_validate.py +63 -1
  40. package/runner/schemas.py +9 -4
  41. package/runner/tests/__pycache__/test_analysis_bridge.cpython-311.pyc +0 -0
  42. package/runner/tests/__pycache__/test_antigravity_plugin.cpython-311.pyc +0 -0
  43. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  44. package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
  45. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  46. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  47. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  48. package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
  49. package/runner/tests/__pycache__/test_config_schema.cpython-311.pyc +0 -0
  50. package/runner/tests/__pycache__/test_contract_tooling.cpython-311.pyc +0 -0
  51. package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
  52. package/runner/tests/__pycache__/test_dep_health.cpython-311.pyc +0 -0
  53. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  54. package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
  55. package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
  56. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  57. package/runner/tests/__pycache__/test_frontier_concurrency.cpython-311.pyc +0 -0
  58. package/runner/tests/__pycache__/test_frontier_mirror_standalone.cpython-311.pyc +0 -0
  59. package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
  60. package/runner/tests/__pycache__/test_hardening_contract.cpython-311.pyc +0 -0
  61. package/runner/tests/__pycache__/test_hook_bus.cpython-311.pyc +0 -0
  62. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  63. package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
  64. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  65. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  66. package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
  67. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  68. package/runner/tests/__pycache__/test_phase08_waiver_reopens.cpython-311.pyc +0 -0
  69. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  70. package/runner/tests/__pycache__/test_search_anomaly.cpython-311.pyc +0 -0
  71. package/runner/tests/__pycache__/test_search_provider.cpython-311.pyc +0 -0
  72. package/runner/tests/__pycache__/test_search_provider_plugin.cpython-311.pyc +0 -0
  73. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  74. package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
  75. package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
  76. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  77. package/runner/tests/__pycache__/test_weight_signals.cpython-311.pyc +0 -0
  78. package/runner/tests/fixtures/config_validation_cases.json +60 -0
  79. package/runner/tests/test_analysis_bridge.py +356 -0
  80. package/runner/tests/test_config_schema.py +119 -15
  81. package/runner/tests/test_dep_health.py +484 -0
  82. package/runner/tests/test_hardening_contract.py +391 -0
  83. package/runner/tests/test_hook_bus.py +802 -0
  84. package/runner/tests/test_opencode_ux.py +232 -0
  85. package/runner/tests/test_opencode_v2_registrars.py +548 -16
  86. package/runner/tests/test_phase08_waiver_reopens.py +539 -0
  87. package/runner/tests/test_weight_signals.py +361 -0
  88. package/schemas/config.schema.json +4 -3
  89. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  90. package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
  91. package/scripts/__pycache__/check_docs.cpython-311.pyc +0 -0
  92. package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
  93. package/scripts/__pycache__/gen_schemas.cpython-311.pyc +0 -0
  94. package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  95. package/skills/factory/SKILL.md +1 -1
  96. package/skills/grilling/__pycache__/__init__.cpython-311.pyc +0 -0
  97. package/skills/grilling/__pycache__/socratic_tree.cpython-311.pyc +0 -0
  98. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  99. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  100. package/skills/research_cache/hasher.py +33 -8
  101. package/skills/swarm_config/SKILL.md +6 -1
  102. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  103. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
  104. package/skills/swarm_config/configure.py +254 -17
@@ -23,6 +23,7 @@ import hashlib
23
23
  import json
24
24
  import os
25
25
  import re
26
+ import stat
26
27
  import sys
27
28
  import tempfile
28
29
  from contextlib import contextmanager
@@ -219,6 +220,38 @@ class ConfigStaleError(ConfigError):
219
220
  )
220
221
 
221
222
 
223
+ class ConfigNullError(ConfigError):
224
+ """A caller-supplied null targets a schema-known non-nullable key.
225
+
226
+ Structural unification (R2): `save_config` emits the same
227
+ `NULL_FOR_NON_NULLABLE_KEY` code as the MCP `_handle_config` pre-check,
228
+ with dotted paths for nested nulls (e.g. `verify.min_fuzzy_confidence`).
229
+ Scoping: only schema-known paths are reported here; nulls under
230
+ unknown top-level keys or unknown roles (e.g. `agents.gamma`) carry no
231
+ nullability info and are preserved-verbatim for generic validation to
232
+ decide (unknown object keys are forward-compatible).
233
+ """
234
+
235
+ code = "NULL_FOR_NON_NULLABLE_KEY"
236
+
237
+ def __init__(self, paths: List[str]):
238
+ names = sorted(set(str(p) for p in paths))
239
+ super().__init__(
240
+ "null is not accepted for non-nullable key(s): "
241
+ + ", ".join(names)
242
+ + "; omit the key to leave it unchanged, or pass a valid value",
243
+ details={"errors": names},
244
+ )
245
+
246
+ @property
247
+ def null_errors(self) -> List[str]:
248
+ return list(self.details.get("errors") or [])
249
+
250
+ @property
251
+ def errors(self) -> List[str]:
252
+ return list(self.details.get("errors") or [])
253
+
254
+
222
255
  class ConfigHashError(ConfigError):
223
256
  """`expected_hash` is not a full sha256 / unique-prefix hex string."""
224
257
 
@@ -232,6 +265,85 @@ class ConfigHashError(ConfigError):
232
265
  )
233
266
 
234
267
 
268
+ class ConfigUnreadableError(ConfigError):
269
+ """The config file exists but is not a readable, bounded regular file.
270
+
271
+ Phase-08 R2 (W17): `save_config` raises this (fail-closed, nothing
272
+ written) instead of hanging on a FIFO/special device or silently merging
273
+ over an unreadable/oversize file.
274
+ """
275
+
276
+ code = "CONFIG_UNREADABLE"
277
+
278
+
279
+ # ---------------------------------------------------------------------------
280
+ # bounded reads (phase-08 R2 FIFO hardening)
281
+ # ---------------------------------------------------------------------------
282
+
283
+ #: Cap for any single `config.json` read. The file is ~1KB in practice; the
284
+ #: cap exists only so a pathological workspace entry can never wedge a reader
285
+ #: on an unbounded slurp (mirrors `runner/preflight.py::STATE_MAX_BYTES`).
286
+ CONFIG_MAX_BYTES = 1024 * 1024
287
+
288
+
289
+ def _read_bounded_bytes(
290
+ path: Path, max_bytes: int = CONFIG_MAX_BYTES
291
+ ) -> Optional[bytes]:
292
+ """Read at most `max_bytes` from `path` iff it is a regular file.
293
+
294
+ Phase-08 R2 (W17): a FIFO named `config.json` hung `load_config` /
295
+ `heal_config` on a plain `open`. Ports the `runner/preflight.py`
296
+ `_read_bounded_text` semantics — `S_ISREG` on BOTH the pre-open `stat`
297
+ and the post-open `fstat` (closing the stat->open race), an `O_NONBLOCK`
298
+ open, and a byte cap — so a pathological workspace entry (FIFO, device,
299
+ socket, directory, runaway file) can never block a config reader.
300
+ Returns `None` when the path is absent, unreadable, non-regular, or
301
+ over the cap.
302
+
303
+ Symlinks are FOLLOWED (`os.stat`, no `O_NOFOLLOW`): the pre-fix reader
304
+ followed them too, so a symlinked config keeps working; a symlink to a
305
+ non-regular target is still refused by the `S_ISREG` checks. Residual
306
+ (documented): a symlink swapped for a FIFO between the pre-open stat and
307
+ the open races the check — the post-open `fstat` still refuses anything
308
+ that is non-regular at read time.
309
+ """
310
+ try:
311
+ if not stat.S_ISREG(os.stat(path).st_mode):
312
+ return None
313
+ except OSError:
314
+ return None
315
+ flags = os.O_RDONLY | getattr(os, "O_NONBLOCK", 0)
316
+ try:
317
+ fd = os.open(path, flags)
318
+ except OSError:
319
+ return None
320
+ try:
321
+ try:
322
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
323
+ return None
324
+ except OSError:
325
+ return None
326
+ chunks = []
327
+ remaining = max_bytes + 1
328
+ while remaining > 0:
329
+ try:
330
+ chunk = os.read(fd, min(remaining, 65536))
331
+ except BlockingIOError:
332
+ break
333
+ except OSError:
334
+ return None
335
+ if not chunk:
336
+ break
337
+ chunks.append(chunk)
338
+ remaining -= len(chunk)
339
+ finally:
340
+ os.close(fd)
341
+ data = b"".join(chunks)
342
+ if len(data) > max_bytes:
343
+ return None
344
+ return data
345
+
346
+
235
347
  # ---------------------------------------------------------------------------
236
348
  # paths + snapshots
237
349
  # ---------------------------------------------------------------------------
@@ -250,11 +362,15 @@ def config_hash(base_dir: str = DEFAULT_RESEARCH_DIR) -> Optional[str]:
250
362
 
251
363
  The hash covers the file as written (whitespace included), so it doubles as
252
364
  an optimistic-concurrency version for `expected_hash` / the RPC bridge.
365
+ Phase-08 R2: read through the bounded reader, so a FIFO/special device
366
+ yields None (it has no stable bytes to hash; a guarded write then fails
367
+ closed as stale) instead of hanging the caller.
253
368
  """
254
369
  cfg_file = get_config_path(base_dir)
255
- if not cfg_file.is_file():
370
+ raw = _read_bounded_bytes(cfg_file)
371
+ if raw is None:
256
372
  return None
257
- return hashlib.sha256(cfg_file.read_bytes()).hexdigest()
373
+ return hashlib.sha256(raw).hexdigest()
258
374
 
259
375
 
260
376
  def snapshot(base_dir: str = DEFAULT_RESEARCH_DIR) -> Dict[str, Any]:
@@ -327,14 +443,83 @@ def validate_config(cfg: Any) -> List[str]:
327
443
 
328
444
 
329
445
  def _read_raw_config(cfg_file: Path) -> Optional[Dict[str, Any]]:
330
- """Best-effort raw JSON object on disk (None when absent/malformed)."""
446
+ """Best-effort raw JSON object on disk (None when absent/malformed).
447
+
448
+ Phase-08 R2: read through the bounded reader, so a FIFO/special device
449
+ yields None (callers already treat None as absent/malformed) instead of
450
+ hanging the caller.
451
+ """
331
452
  try:
332
- data = json.loads(cfg_file.read_text(encoding="utf-8"))
453
+ raw = _read_bounded_bytes(cfg_file)
454
+ if raw is None:
455
+ return None
456
+ data = json.loads(raw.decode("utf-8"))
333
457
  except (OSError, ValueError):
334
458
  return None
335
459
  return data if isinstance(data, dict) else None
336
460
 
337
461
 
462
+ def _caller_schema_allows_null(subschema: Any) -> bool:
463
+ """True when a canonical subschema accepts null (type includes "null")."""
464
+ if not isinstance(subschema, dict):
465
+ return False
466
+ types = subschema.get("type")
467
+ if isinstance(types, list):
468
+ return "null" in types
469
+ return types == "null"
470
+
471
+
472
+ def collect_caller_null_violations(caller: Any) -> List[str]:
473
+ """Dotted paths in caller-supplied `caller` where null is banned (R2).
474
+
475
+ Shared nested-null pre-check: `save_config` raises `ConfigNullError` for
476
+ these paths so ALL writers emit `NULL_FOR_NON_NULLABLE_KEY` for
477
+ schema-known paths (unifying the MCP `_handle_config` pre-check). Only
478
+ schema-known paths are reported; nulls under unknown top-level keys or
479
+ unknown roles (e.g. `agents.gamma`) carry no nullability info and are
480
+ preserved-verbatim for generic validation to decide. Degrades to `[]`
481
+ when the canonical schema is unavailable (standalone skill copy).
482
+ """
483
+ if not isinstance(caller, dict):
484
+ return []
485
+ if _CONFIG_SCHEMA is None:
486
+ return []
487
+ props = _CONFIG_SCHEMA.get("properties") or {}
488
+ found: List[str] = []
489
+
490
+ def walk(value: Any, subschema: Any, path: str) -> None:
491
+ if value is None:
492
+ if isinstance(subschema, dict) and not _caller_schema_allows_null(
493
+ subschema
494
+ ):
495
+ found.append(path)
496
+ return
497
+ if isinstance(value, dict) and isinstance(subschema, dict):
498
+ sub_props = subschema.get("properties") or {}
499
+ additional = subschema.get("additionalProperties")
500
+ for key, child in value.items():
501
+ if key in sub_props:
502
+ walk(child, sub_props[key], f"{path}.{key}" if path else str(key))
503
+ elif isinstance(additional, dict):
504
+ walk(child, additional, f"{path}.{key}" if path else str(key))
505
+ return
506
+ if isinstance(value, list) and isinstance(subschema, dict):
507
+ items = subschema.get("items")
508
+ if isinstance(items, dict):
509
+ for idx, child in enumerate(value):
510
+ walk(child, items, f"{path}[{idx}]")
511
+
512
+ for key, value in caller.items():
513
+ if key not in props:
514
+ continue # unknown top-level keys: preserved verbatim
515
+ if value is None:
516
+ if not _caller_schema_allows_null(props[key]):
517
+ found.append(str(key))
518
+ elif isinstance(value, (dict, list)):
519
+ walk(value, props[key], str(key))
520
+ return sorted(set(found))
521
+
522
+
338
523
  # ---------------------------------------------------------------------------
339
524
  # load / save
340
525
  # ---------------------------------------------------------------------------
@@ -392,17 +577,30 @@ def load_config(base_dir: str = DEFAULT_RESEARCH_DIR) -> Dict[str, Any]:
392
577
  """Load config merged over defaults. Pure — never writes to disk.
393
578
 
394
579
  Applies the legacy-pin migration in memory so a stale config stops forcing
395
- Claude immediately; `heal_config` persists that migration on next touch.
396
- Unknown keys are preserved verbatim (they are part of the contract's
397
- forward-compatibility rule).
580
+ Claude immediately. No production read path persists that migration (reads
581
+ are non-healing by design, R12/F2): it is applied in memory on every load
582
+ and persisted opportunistically the next time a write leg merges the loaded
583
+ config (`configure.py` CLI save, the MCP `iumbtems_config` update leg);
584
+ `heal_config` remains available as an explicit opt-in repair helper but no
585
+ production read path calls it (phase-08 R3). Unknown keys are preserved
586
+ verbatim (they are part of the contract's forward-compatibility rule).
587
+
588
+ Phase-08 R2: the file is read through the bounded reader, so a FIFO /
589
+ special device / oversize file warns once and yields defaults instead of
590
+ hanging the caller.
398
591
  """
399
592
  base = copy.deepcopy(DEFAULT_CONFIG)
400
593
  cfg_file = get_config_path(base_dir)
401
- if not cfg_file.exists():
594
+ raw_bytes = _read_bounded_bytes(cfg_file)
595
+ if raw_bytes is None:
596
+ if os.path.lexists(cfg_file):
597
+ sys.stderr.write(
598
+ f"[swarm-config] Warning: {cfg_file} is not a readable regular "
599
+ "file (or exceeds the read cap). Using defaults.\n"
600
+ )
402
601
  return base
403
602
  try:
404
- with open(cfg_file, "r", encoding="utf-8") as f:
405
- data = json.load(f)
603
+ data = json.loads(raw_bytes.decode("utf-8"))
406
604
  except Exception as e:
407
605
  sys.stderr.write(
408
606
  f"[swarm-config] Warning: Failed to parse {cfg_file}: {e}. Using defaults.\n"
@@ -426,21 +624,30 @@ def load_config(base_dir: str = DEFAULT_RESEARCH_DIR) -> Dict[str, Any]:
426
624
  def heal_config(base_dir: str = DEFAULT_RESEARCH_DIR) -> bool:
427
625
  """Persist a legacy agent-backend migration to disk; True if rewritten.
428
626
 
429
- `load_config` stays side-effect free; the CLI and MCP surfaces call this so
430
- an affected install self-heals instead of re-pinning Claude on every save.
431
- The write is guarded by the hash of the bytes this call read, so a migration
432
- never clobbers a concurrent writer (it simply defers to the next touch).
627
+ Explicit opt-in repair helper (exported, tested). Phase-08 R3 truth: NO
628
+ production read path calls this — `--show` / bare CLI, the MCP `show`
629
+ leg, and `load_config` itself are strictly non-healing (R12/F2); the
630
+ migration persists opportunistically when a write leg merges the loaded
631
+ (in-memory-migrated) config. Call this directly when you want the
632
+ migration persisted outside any other write. The write is guarded by the
633
+ hash of the bytes this call read, so a migration never clobbers a
634
+ concurrent writer (it simply defers to the next touch).
433
635
 
434
636
  The would-be-persisted dict is validated first (R1): a file that already
435
637
  fails the canonical schema is NOT rewritten — persisting it would legitimise
436
638
  the invalid values and feed them to SwarmRunner. The migration then stays in
437
639
  memory only, and a warning names every violation.
640
+
641
+ Phase-08 R2: the pre-read goes through the bounded reader, so a FIFO /
642
+ special device returns False instead of hanging the caller.
438
643
  """
439
644
  cfg_file = get_config_path(base_dir)
440
645
  if not cfg_file.exists():
441
646
  return False
442
647
  try:
443
- raw_bytes = cfg_file.read_bytes()
648
+ raw_bytes = _read_bounded_bytes(cfg_file)
649
+ if raw_bytes is None:
650
+ return False
444
651
  raw = json.loads(raw_bytes.decode("utf-8"))
445
652
  except Exception:
446
653
  return False
@@ -486,7 +693,11 @@ def save_config(
486
693
  * Cross-process advisory lock over `.research/.config.lock` around the whole
487
694
  read-merge-write, so concurrent writers cannot lose each other's keys.
488
695
  * Unknown keys already on disk survive; `cfg` keys (deep-merged) win.
489
- * Schema validation against `runner.schemas.CONFIG`; failures raise
696
+ * Caller-supplied null for a schema-known non-nullable key (any depth)
697
+ raises `ConfigNullError` (`NULL_FOR_NON_NULLABLE_KEY`) with dotted paths
698
+ before anything is written — the same code as the MCP surface; nulls
699
+ under unknown keys/roles stay preserved-verbatim for generic validation.
700
+ * Schema validation against `runner.schemas.CONFIG`; other failures raise
490
701
  `ConfigValidationError` with dotted paths and write nothing. `validate`
491
702
  may be disabled only by a caller that has already validated the exact
492
703
  dict it is persisting (see `heal_config`).
@@ -503,13 +714,39 @@ def save_config(
503
714
  "(config must be a JSON object)"
504
715
  ]
505
716
  )
717
+ null_paths = collect_caller_null_violations(cfg)
718
+ if null_paths:
719
+ raise ConfigNullError(null_paths)
506
720
  guard = normalize_expected_hash(expected_hash)
507
721
  target_dir = Path(base_dir)
508
722
  target_dir.mkdir(parents=True, exist_ok=True)
509
723
  cfg_file = target_dir / "config.json"
510
724
 
511
725
  with _file_lock(get_lock_path(base_dir)):
512
- existing_bytes = cfg_file.read_bytes() if cfg_file.is_file() else b""
726
+ # Phase-08 R2: bounded pre-read — a FIFO/special device here hung the
727
+ # writer on a plain `read_bytes`. Fail closed (nothing written) rather
728
+ # than hang or silently merge over an unreadable/oversize file.
729
+ if cfg_file.is_file():
730
+ existing_bytes = _read_bounded_bytes(cfg_file)
731
+ if existing_bytes is None:
732
+ raise ConfigUnreadableError(
733
+ f"{cfg_file} exists but is not a readable, bounded "
734
+ "regular file (FIFO, device, or over the read cap); "
735
+ "refusing to merge over it.",
736
+ details={"written": False},
737
+ )
738
+ elif os.path.lexists(cfg_file):
739
+ # A non-file entry (FIFO, directory, dangling symlink): the old
740
+ # code silently replaced it via merge-over-{} + os.replace.
741
+ # Refuse instead — replacing a caller's FIFO/symlink out from
742
+ # under them is never the safe default.
743
+ raise ConfigUnreadableError(
744
+ f"{cfg_file} exists but is not a regular file; "
745
+ "refusing to merge over it.",
746
+ details={"written": False},
747
+ )
748
+ else:
749
+ existing_bytes = b""
513
750
  current_hash = (
514
751
  hashlib.sha256(existing_bytes).hexdigest() if existing_bytes else None
515
752
  )