@ictechgy/context-guard 0.4.5 → 0.4.6

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/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ All notable changes for the ContextGuard plugin are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.4.6] - 2026-06-10
8
+
9
+ - Hardened local cost ledger/key storage against symlink traversal, unsafe permissions, and partial writes while improving recent-ledger loading performance.
10
+ - Replaced Pages publishing with least-privilege GitHub Pages artifact deployment and pinned first-party Actions.
11
+ - Hardened the macOS audit adapter execution boundary, output caps, temp directory permissions, and Swift CI coverage.
12
+ - Made context pack outputs and receipts use atomic same-directory writes.
13
+ - Added `scripts/sync_plugin_copies.py` so duplicated plugin bin/lib copies are reproducible, symlink-safe, mode-checked, and covered by release gates.
14
+
7
15
  ## [0.4.5] - 2026-06-09
8
16
 
9
17
  - Added a package-visible `mac_visibility` feasibility contract for future local macOS-visible surfaces without building a GUI or inferring live headroom from historical transcript scans.
package/README.md CHANGED
@@ -415,14 +415,15 @@ context-guard-setup --plan
415
415
 
416
416
  ## Release checks
417
417
 
418
- Before publishing or merging release-sensitive changes, run both gates:
418
+ Before publishing or merging release-sensitive changes, run the copy check and both gates:
419
419
 
420
420
  ```bash
421
+ python3 scripts/sync_plugin_copies.py --check
421
422
  python3 scripts/prepublish_check.py
422
423
  python3 scripts/release_smoke.py
423
424
  ```
424
425
 
425
- `prepublish_check.py` verifies package invariants, synchronized plugin binaries, manifests, diagnostic redaction, and the regression suite. `release_smoke.py` executes representative packaged entrypoints from `plugins/context-guard/bin` in a temporary project so broken CLI wiring is caught before publish. See [docs/release-runbook.md](docs/release-runbook.md) for the full release workflow, evidence checklist, quad-review requirement, and rollback checklist.
426
+ When a helper under `context-guard-kit/` changes, run `python3 scripts/sync_plugin_copies.py --write` before the gates. `sync_plugin_copies.py --check` verifies the exact-copy contract up front; `prepublish_check.py` verifies package invariants, synchronized plugin binaries, manifests, diagnostic redaction, and the regression suite. `release_smoke.py` executes representative packaged entrypoints from `plugins/context-guard/bin` in a temporary project so broken CLI wiring is caught before publish. See [docs/release-runbook.md](docs/release-runbook.md) for the full release workflow, evidence checklist, quad-review requirement, and rollback checklist.
426
427
 
427
428
  Versioned release notes live in [CHANGELOG.md](CHANGELOG.md); the prepublish gate requires an entry matching the plugin manifest version before publishing.
428
429
 
@@ -729,29 +729,95 @@ def ensure_private_pack_dir(root: Path) -> tuple[Path | None, int | None, str |
729
729
  pass
730
730
 
731
731
 
732
- def write_private_json_at(dir_fd: int, filename: str, data: dict[str, Any]) -> None:
732
+ def atomic_write_ops_supported() -> bool:
733
+ return (
734
+ os.open in os.supports_dir_fd
735
+ and os.rename in os.supports_dir_fd
736
+ and os.unlink in os.supports_dir_fd
737
+ )
738
+
739
+
740
+ def fsync_dir_fd(dir_fd: int) -> None:
741
+ os.fsync(dir_fd)
742
+
743
+
744
+ def validate_existing_output_target_at(dir_fd: int, filename: str, option_name: str) -> None:
745
+ flags = os.O_WRONLY
746
+ if hasattr(os, "O_NOFOLLOW"):
747
+ flags |= os.O_NOFOLLOW
748
+ if hasattr(os, "O_CLOEXEC"):
749
+ flags |= os.O_CLOEXEC
750
+ if hasattr(os, "O_NONBLOCK"):
751
+ flags |= os.O_NONBLOCK
752
+ file_fd = -1
753
+ try:
754
+ file_fd = os.open(filename, flags, dir_fd=dir_fd)
755
+ st = os.fstat(file_fd)
756
+ if not stat.S_ISREG(st.st_mode):
757
+ raise PackError(f"invalid {option_name}: unsafe_path")
758
+ except FileNotFoundError:
759
+ return
760
+ except IsADirectoryError as exc:
761
+ raise PackError(f"invalid {option_name}: unsafe_path") from exc
762
+ except OSError as exc:
763
+ raise PackError(f"invalid {option_name}: {exc.strerror or exc.__class__.__name__}") from exc
764
+ finally:
765
+ if file_fd >= 0:
766
+ try:
767
+ os.close(file_fd)
768
+ except OSError:
769
+ pass
770
+
771
+
772
+ def write_text_atomic_at(dir_fd: int, filename: str, content: str, *, mode: int, option_name: str) -> None:
733
773
  if "/" in filename or filename in {"", ".", ".."}:
734
- raise PackError("unsafe_artifact_path")
735
- flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
774
+ raise PackError(f"invalid {option_name}: unsafe_path")
775
+ if not atomic_write_ops_supported():
776
+ raise PackError(f"invalid {option_name}: atomic_write_unsupported")
777
+ validate_existing_output_target_at(dir_fd, filename, option_name)
778
+ digest = hashlib.sha256(f"{filename}:{os.getpid()}:{time.time_ns()}".encode("utf-8", "replace")).hexdigest()[:16]
779
+ temp_name = f".context-guard-pack-{digest}.tmp"
780
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
736
781
  if hasattr(os, "O_NOFOLLOW"):
737
782
  flags |= os.O_NOFOLLOW
738
783
  if hasattr(os, "O_CLOEXEC"):
739
784
  flags |= os.O_CLOEXEC
740
- fd = os.open(filename, flags, 0o600, dir_fd=dir_fd)
785
+ fd = -1
786
+ temp_created = False
741
787
  try:
742
- with os.fdopen(fd, "w", encoding="utf-8") as handle:
743
- json.dump(data, handle, ensure_ascii=False, indent=2, sort_keys=True)
744
- handle.write("\n")
745
- except Exception:
788
+ fd = os.open(temp_name, flags, mode, dir_fd=dir_fd)
789
+ temp_created = True
790
+ with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle:
791
+ fd = -1
792
+ handle.write(content)
793
+ handle.flush()
794
+ os.fsync(handle.fileno())
795
+ fsync_dir_fd(dir_fd)
796
+ os.rename(temp_name, filename, src_dir_fd=dir_fd, dst_dir_fd=dir_fd)
797
+ temp_created = False
746
798
  try:
747
- os.close(fd)
748
- except OSError:
799
+ os.chmod(filename, mode, dir_fd=dir_fd, follow_symlinks=False)
800
+ except (OSError, TypeError, NotImplementedError):
749
801
  pass
750
- raise
751
- try:
752
- os.chmod(filename, 0o600, dir_fd=dir_fd, follow_symlinks=False)
753
- except (OSError, TypeError, NotImplementedError):
754
- pass
802
+ fsync_dir_fd(dir_fd)
803
+ finally:
804
+ if fd >= 0:
805
+ try:
806
+ os.close(fd)
807
+ except OSError:
808
+ pass
809
+ if temp_created:
810
+ try:
811
+ os.unlink(temp_name, dir_fd=dir_fd)
812
+ except OSError:
813
+ pass
814
+
815
+
816
+ def write_private_json_at(dir_fd: int, filename: str, data: dict[str, Any]) -> None:
817
+ if "/" in filename or filename in {"", ".", ".."}:
818
+ raise PackError("unsafe_artifact_path")
819
+ content = json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
820
+ write_text_atomic_at(dir_fd, filename, content, mode=0o600, option_name="artifact receipt")
755
821
 
756
822
 
757
823
  def finalize_receipt_size(receipt: dict[str, Any]) -> int:
@@ -1453,27 +1519,13 @@ def write_text_under_root(root: Path, raw_path: str, content: str, option_name:
1453
1519
  parent_parts = rel.parts[:-1]
1454
1520
  filename = rel.parts[-1]
1455
1521
  current_fd: int | None = None
1456
- file_fd = -1
1457
1522
  try:
1458
1523
  current_fd = open_dir_no_follow(root)
1459
1524
  for part in parent_parts:
1460
1525
  next_fd = open_dir_no_follow(part, dir_fd=current_fd)
1461
1526
  os.close(current_fd)
1462
1527
  current_fd = next_fd
1463
- flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
1464
- if hasattr(os, "O_NOFOLLOW"):
1465
- flags |= os.O_NOFOLLOW
1466
- if hasattr(os, "O_CLOEXEC"):
1467
- flags |= os.O_CLOEXEC
1468
- if hasattr(os, "O_NONBLOCK"):
1469
- flags |= os.O_NONBLOCK
1470
- file_fd = os.open(filename, flags, 0o600, dir_fd=current_fd)
1471
- st = os.fstat(file_fd)
1472
- if not stat.S_ISREG(st.st_mode):
1473
- raise PackError(f"invalid {option_name}: unsafe_path")
1474
- with os.fdopen(file_fd, "w", encoding="utf-8") as handle:
1475
- file_fd = -1
1476
- handle.write(content)
1528
+ write_text_atomic_at(current_fd, filename, content, mode=0o600, option_name=option_name)
1477
1529
  except PackError:
1478
1530
  raise
1479
1531
  except FileNotFoundError as exc:
@@ -1481,11 +1533,6 @@ def write_text_under_root(root: Path, raw_path: str, content: str, option_name:
1481
1533
  except OSError as exc:
1482
1534
  raise PackError(f"invalid {option_name}: {exc.strerror or exc.__class__.__name__}") from exc
1483
1535
  finally:
1484
- if file_fd >= 0:
1485
- try:
1486
- os.close(file_fd)
1487
- except OSError:
1488
- pass
1489
1536
  if current_fd is not None:
1490
1537
  try:
1491
1538
  os.close(current_fd)