@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 +8 -0
- package/README.md +3 -2
- package/context-guard-kit/context_pack.py +82 -35
- package/context-guard-kit/cost_guard.py +457 -45
- package/package.json +1 -1
- package/packaging/homebrew/context-guard.rb.template +1 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/bin/context-guard-cost +457 -45
- package/plugins/context-guard/bin/context-guard-pack +82 -35
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
|
|
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("
|
|
735
|
-
|
|
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 =
|
|
785
|
+
fd = -1
|
|
786
|
+
temp_created = False
|
|
741
787
|
try:
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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.
|
|
748
|
-
except OSError:
|
|
799
|
+
os.chmod(filename, mode, dir_fd=dir_fd, follow_symlinks=False)
|
|
800
|
+
except (OSError, TypeError, NotImplementedError):
|
|
749
801
|
pass
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
-
|
|
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)
|