@ictechgy/context-guard 0.4.5 → 0.4.7
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 +15 -0
- package/README.ko.md +32 -4
- package/README.md +36 -5
- package/context-guard-kit/README.md +2 -0
- package/context-guard-kit/context_escrow.py +22 -6
- package/context-guard-kit/context_guard_cli.py +1 -0
- package/context-guard-kit/context_pack.py +82 -35
- package/context-guard-kit/cost_guard.py +457 -45
- package/context-guard-kit/experimental_registry.py +2038 -0
- package/docs/benchmark-workflow-examples.md +1 -1
- package/docs/experimental-benchmark-fixtures.md +1 -1
- package/package.json +2 -1
- package/packaging/homebrew/context-guard.rb.template +1 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/README.ko.md +1 -1
- package/plugins/context-guard/README.md +19 -1
- package/plugins/context-guard/bin/context-guard +1 -0
- package/plugins/context-guard/bin/context-guard-artifact +22 -6
- package/plugins/context-guard/bin/context-guard-cost +457 -45
- package/plugins/context-guard/bin/context-guard-experiments +2038 -0
- package/plugins/context-guard/bin/context-guard-pack +82 -35
|
@@ -11,6 +11,10 @@ from __future__ import annotations
|
|
|
11
11
|
import argparse
|
|
12
12
|
import base64
|
|
13
13
|
import binascii
|
|
14
|
+
try:
|
|
15
|
+
import fcntl
|
|
16
|
+
except ImportError: # pragma: no cover - fcntl is unavailable on Windows.
|
|
17
|
+
fcntl = None
|
|
14
18
|
import hashlib
|
|
15
19
|
import hmac
|
|
16
20
|
import json
|
|
@@ -44,9 +48,16 @@ DEFAULT_USD_TO_KRW = 1350.0
|
|
|
44
48
|
DEFAULT_SAFETY_FACTOR = 1.25
|
|
45
49
|
DEFAULT_LARGE_SECTION_BYTES = 64_000
|
|
46
50
|
MAX_LEDGER_ROWS = 20_000
|
|
51
|
+
LEDGER_TAIL_INITIAL_BYTES = 64 * 1024
|
|
47
52
|
TTL_SECONDS = {"5m": 5 * 60, "1h": 60 * 60}
|
|
48
53
|
ANTHROPIC_DOCS_URL = "https://docs.anthropic.com/en/build-with-claude/prompt-caching"
|
|
49
54
|
ANTHROPIC_PRICING_URL = "https://platform.claude.com/docs/en/about-claude/pricing"
|
|
55
|
+
ALLOWED_FIRST_COMPONENT_SYMLINKS = {
|
|
56
|
+
"tmp": Path("/private/tmp"),
|
|
57
|
+
"var": Path("/private/var"),
|
|
58
|
+
}
|
|
59
|
+
DIR_FD_OPEN_SUPPORTED = os.open in getattr(os, "supports_dir_fd", set())
|
|
60
|
+
NO_FOLLOW_SUPPORTED = hasattr(os, "O_NOFOLLOW")
|
|
50
61
|
|
|
51
62
|
SECRET_RE = re.compile(
|
|
52
63
|
r"(?is)("
|
|
@@ -449,11 +460,24 @@ def extract_cache_breakpoints(request: Any) -> tuple[list[CacheBreakpoint], dict
|
|
|
449
460
|
|
|
450
461
|
|
|
451
462
|
def ensure_private_dir(path: Path) -> None:
|
|
452
|
-
path
|
|
463
|
+
path = reject_symlink_components(path, label="local HMAC ledger directory")
|
|
464
|
+
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
465
|
+
path = reject_symlink_components(path, label="local HMAC ledger directory")
|
|
466
|
+
if not path.is_dir():
|
|
467
|
+
fail("local HMAC ledger directory must be a directory")
|
|
453
468
|
try:
|
|
454
469
|
os.chmod(path, 0o700)
|
|
455
|
-
except OSError:
|
|
456
|
-
|
|
470
|
+
except OSError as exc:
|
|
471
|
+
if os.name == "posix":
|
|
472
|
+
fail(f"could not secure local HMAC ledger directory: {os_error_detail(exc)}")
|
|
473
|
+
return
|
|
474
|
+
if os.name == "posix":
|
|
475
|
+
try:
|
|
476
|
+
mode = stat.S_IMODE(path.stat().st_mode)
|
|
477
|
+
except OSError as exc:
|
|
478
|
+
fail(f"could not verify local HMAC ledger directory privacy: {os_error_detail(exc)}")
|
|
479
|
+
if mode != 0o700:
|
|
480
|
+
fail("could not verify local HMAC ledger directory privacy: expected mode 0700")
|
|
457
481
|
|
|
458
482
|
|
|
459
483
|
def os_error_detail(exc: OSError) -> str:
|
|
@@ -463,33 +487,246 @@ def os_error_detail(exc: OSError) -> str:
|
|
|
463
487
|
return detail
|
|
464
488
|
|
|
465
489
|
|
|
490
|
+
def _base_open_flags() -> int:
|
|
491
|
+
flags = os.O_RDONLY
|
|
492
|
+
if hasattr(os, "O_CLOEXEC"):
|
|
493
|
+
flags |= os.O_CLOEXEC
|
|
494
|
+
return flags
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _no_follow_flag() -> int:
|
|
498
|
+
if not NO_FOLLOW_SUPPORTED:
|
|
499
|
+
fail("private local cost storage requires O_NOFOLLOW support")
|
|
500
|
+
return os.O_NOFOLLOW
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _directory_open_flags(*, follow_final: bool = False) -> int:
|
|
504
|
+
flags = os.O_RDONLY
|
|
505
|
+
if hasattr(os, "O_CLOEXEC"):
|
|
506
|
+
flags |= os.O_CLOEXEC
|
|
507
|
+
if hasattr(os, "O_DIRECTORY"):
|
|
508
|
+
flags |= os.O_DIRECTORY
|
|
509
|
+
if not follow_final:
|
|
510
|
+
flags |= _no_follow_flag()
|
|
511
|
+
return flags
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def dir_fd_open_supported() -> bool:
|
|
515
|
+
return DIR_FD_OPEN_SUPPORTED
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _private_leaf_name(path: Path, *, label: str) -> str:
|
|
519
|
+
name = path.name
|
|
520
|
+
if name in {"", ".", ".."}:
|
|
521
|
+
fail(f"{label} must name a private file")
|
|
522
|
+
return name
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _normalized_link_target(anchor: Path, raw_target: str) -> Path:
|
|
526
|
+
target = Path(raw_target)
|
|
527
|
+
if target.is_absolute():
|
|
528
|
+
return Path(os.path.normpath(str(target)))
|
|
529
|
+
return Path(os.path.normpath(str(anchor / target)))
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def normalize_allowed_first_absolute_symlink(path: Path) -> Path:
|
|
533
|
+
"""Normalize macOS /tmp and /var first-component symlinks only.
|
|
534
|
+
|
|
535
|
+
Other symlink components are refused before reading or writing private local
|
|
536
|
+
ledger/key material.
|
|
537
|
+
"""
|
|
538
|
+
|
|
539
|
+
if not path.is_absolute():
|
|
540
|
+
return path
|
|
541
|
+
parts = path.parts
|
|
542
|
+
if len(parts) < 2:
|
|
543
|
+
return path
|
|
544
|
+
first = parts[1]
|
|
545
|
+
expected = ALLOWED_FIRST_COMPONENT_SYMLINKS.get(first)
|
|
546
|
+
if expected is None:
|
|
547
|
+
return path
|
|
548
|
+
link = Path(path.anchor) / first
|
|
549
|
+
try:
|
|
550
|
+
if link.is_symlink() and _normalized_link_target(Path(path.anchor), os.readlink(link)) == expected:
|
|
551
|
+
return expected.joinpath(*parts[2:])
|
|
552
|
+
except OSError:
|
|
553
|
+
return path
|
|
554
|
+
return path
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def reject_symlink_components(path: Path, *, label: str) -> Path:
|
|
558
|
+
path = normalize_allowed_first_absolute_symlink(path)
|
|
559
|
+
current = Path(path.anchor) if path.is_absolute() else Path()
|
|
560
|
+
parts = path.parts[1:] if path.is_absolute() else path.parts
|
|
561
|
+
for part in parts:
|
|
562
|
+
if part in {"", "."}:
|
|
563
|
+
continue
|
|
564
|
+
if part == "..":
|
|
565
|
+
fail(f"{label} must not contain parent traversal")
|
|
566
|
+
current = current / part
|
|
567
|
+
try:
|
|
568
|
+
if current.is_symlink():
|
|
569
|
+
fail(f"{label} must not traverse symlinks")
|
|
570
|
+
except OSError as exc:
|
|
571
|
+
fail(f"could not inspect {label}: {os_error_detail(exc)}")
|
|
572
|
+
return path
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def open_private_directory(path: Path, *, label: str) -> int:
|
|
576
|
+
"""Open an existing directory without following symlink path components."""
|
|
577
|
+
|
|
578
|
+
if not dir_fd_open_supported():
|
|
579
|
+
fail(f"{label} requires dir_fd support for symlink-safe private storage")
|
|
580
|
+
path = reject_symlink_components(path, label=label)
|
|
581
|
+
flags = _directory_open_flags()
|
|
582
|
+
if path.is_absolute():
|
|
583
|
+
anchor = path.anchor or os.sep
|
|
584
|
+
parts = path.parts[1:]
|
|
585
|
+
try:
|
|
586
|
+
current_fd = os.open(anchor, _directory_open_flags(follow_final=True))
|
|
587
|
+
except OSError as exc:
|
|
588
|
+
fail(f"could not inspect {label}: {os_error_detail(exc)}")
|
|
589
|
+
else:
|
|
590
|
+
parts = path.parts
|
|
591
|
+
try:
|
|
592
|
+
current_fd = os.open(".", flags)
|
|
593
|
+
except OSError as exc:
|
|
594
|
+
fail(f"could not inspect {label}: {os_error_detail(exc)}")
|
|
595
|
+
try:
|
|
596
|
+
for part in parts:
|
|
597
|
+
if part in {"", "."}:
|
|
598
|
+
continue
|
|
599
|
+
if part == "..":
|
|
600
|
+
fail(f"{label} must not contain parent traversal")
|
|
601
|
+
next_fd = -1
|
|
602
|
+
try:
|
|
603
|
+
next_fd = os.open(part, flags, dir_fd=current_fd)
|
|
604
|
+
st = os.fstat(next_fd)
|
|
605
|
+
if not stat.S_ISDIR(st.st_mode):
|
|
606
|
+
fail(f"{label} must not traverse non-directory components")
|
|
607
|
+
except CostGuardError:
|
|
608
|
+
if next_fd >= 0:
|
|
609
|
+
try:
|
|
610
|
+
os.close(next_fd)
|
|
611
|
+
except OSError:
|
|
612
|
+
pass
|
|
613
|
+
raise
|
|
614
|
+
except OSError as exc:
|
|
615
|
+
if next_fd >= 0:
|
|
616
|
+
try:
|
|
617
|
+
os.close(next_fd)
|
|
618
|
+
except OSError:
|
|
619
|
+
pass
|
|
620
|
+
fail(f"could not inspect {label}: {os_error_detail(exc)}")
|
|
621
|
+
finally:
|
|
622
|
+
try:
|
|
623
|
+
os.close(current_fd)
|
|
624
|
+
except OSError:
|
|
625
|
+
pass
|
|
626
|
+
current_fd = next_fd
|
|
627
|
+
owned_fd = current_fd
|
|
628
|
+
current_fd = -1
|
|
629
|
+
return owned_fd
|
|
630
|
+
finally:
|
|
631
|
+
if current_fd >= 0:
|
|
632
|
+
try:
|
|
633
|
+
os.close(current_fd)
|
|
634
|
+
except OSError:
|
|
635
|
+
pass
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def fsync_directory_fd(fd: int) -> None:
|
|
639
|
+
if os.name != "posix":
|
|
640
|
+
return
|
|
641
|
+
try:
|
|
642
|
+
os.fsync(fd)
|
|
643
|
+
except OSError:
|
|
644
|
+
pass
|
|
645
|
+
|
|
646
|
+
|
|
466
647
|
def lock_guidance() -> str:
|
|
467
648
|
return f"<store-dir>/{KEY_NAME}.lock"
|
|
468
649
|
|
|
469
650
|
|
|
470
|
-
def ensure_hmac_key_private_mode(key_path: Path) -> None:
|
|
651
|
+
def ensure_hmac_key_private_mode(key_path: Path, *, label: str = "local HMAC key file") -> None:
|
|
471
652
|
try:
|
|
472
653
|
os.chmod(key_path, 0o600)
|
|
473
654
|
except OSError as exc:
|
|
474
655
|
if os.name == "posix":
|
|
475
|
-
fail(f"could not secure
|
|
656
|
+
fail(f"could not secure {label}: {os_error_detail(exc)}")
|
|
476
657
|
return
|
|
477
658
|
if os.name == "posix":
|
|
478
659
|
try:
|
|
479
660
|
mode = stat.S_IMODE(key_path.stat().st_mode)
|
|
480
661
|
except OSError as exc:
|
|
481
|
-
fail(f"could not verify
|
|
662
|
+
fail(f"could not verify {label} privacy: {os_error_detail(exc)}")
|
|
482
663
|
if mode != 0o600:
|
|
483
|
-
fail("could not verify
|
|
664
|
+
fail(f"could not verify {label} privacy: expected mode 0600")
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def open_private_regular_fd_for_read(path: Path, *, label: str) -> int:
|
|
668
|
+
path = normalize_allowed_first_absolute_symlink(path)
|
|
669
|
+
leaf_name = _private_leaf_name(path, label=label)
|
|
670
|
+
try:
|
|
671
|
+
if path.is_symlink():
|
|
672
|
+
fail(f"{label} must not be a symlink")
|
|
673
|
+
except OSError as exc:
|
|
674
|
+
fail(f"could not inspect {label}: {os_error_detail(exc)}")
|
|
675
|
+
parent_fd = -1
|
|
676
|
+
fd = -1
|
|
677
|
+
try:
|
|
678
|
+
parent_fd = open_private_directory(path.parent, label=f"{label} parent")
|
|
679
|
+
fd = os.open(leaf_name, _base_open_flags() | _no_follow_flag(), dir_fd=parent_fd)
|
|
680
|
+
st = os.fstat(fd)
|
|
681
|
+
if not stat.S_ISREG(st.st_mode):
|
|
682
|
+
fail(f"{label} must be a regular file")
|
|
683
|
+
try:
|
|
684
|
+
os.fchmod(fd, 0o600)
|
|
685
|
+
except (AttributeError, OSError):
|
|
686
|
+
pass
|
|
687
|
+
st = os.fstat(fd)
|
|
688
|
+
if os.name == "posix" and stat.S_IMODE(st.st_mode) != 0o600:
|
|
689
|
+
fail(f"could not verify {label} privacy: expected mode 0600")
|
|
690
|
+
owned_fd = fd
|
|
691
|
+
fd = -1
|
|
692
|
+
return owned_fd
|
|
693
|
+
except CostGuardError:
|
|
694
|
+
raise
|
|
695
|
+
except OSError as exc:
|
|
696
|
+
fail(f"could not read {label}: {os_error_detail(exc)}")
|
|
697
|
+
finally:
|
|
698
|
+
if fd >= 0:
|
|
699
|
+
try:
|
|
700
|
+
os.close(fd)
|
|
701
|
+
except OSError:
|
|
702
|
+
pass
|
|
703
|
+
if parent_fd >= 0:
|
|
704
|
+
try:
|
|
705
|
+
os.close(parent_fd)
|
|
706
|
+
except OSError:
|
|
707
|
+
pass
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def open_private_regular_file_for_read(path: Path, *, label: str):
|
|
711
|
+
fd = open_private_regular_fd_for_read(path, label=label)
|
|
712
|
+
try:
|
|
713
|
+
handle = os.fdopen(fd, "r", encoding="utf-8")
|
|
714
|
+
fd = -1
|
|
715
|
+
return handle
|
|
716
|
+
finally:
|
|
717
|
+
if fd >= 0:
|
|
718
|
+
try:
|
|
719
|
+
os.close(fd)
|
|
720
|
+
except OSError:
|
|
721
|
+
pass
|
|
484
722
|
|
|
485
723
|
|
|
486
724
|
def read_hmac_key(key_path: Path) -> bytes:
|
|
487
725
|
try:
|
|
488
|
-
|
|
726
|
+
with open_private_regular_file_for_read(key_path, label="local HMAC key file") as handle:
|
|
727
|
+
raw = handle.read()
|
|
489
728
|
except UnicodeError:
|
|
490
729
|
fail("invalid local HMAC key file: expected UTF-8 canonical URL-safe base64 text")
|
|
491
|
-
except OSError as exc:
|
|
492
|
-
fail(f"could not read local HMAC key file: {os_error_detail(exc)}")
|
|
493
730
|
try:
|
|
494
731
|
raw_ascii = raw.encode("ascii")
|
|
495
732
|
except UnicodeEncodeError:
|
|
@@ -504,7 +741,6 @@ def read_hmac_key(key_path: Path) -> bytes:
|
|
|
504
741
|
fail("invalid local HMAC key file: expected canonical URL-safe 32-byte key")
|
|
505
742
|
if len(key) != 32:
|
|
506
743
|
fail("invalid local HMAC key file: expected 32 decoded bytes")
|
|
507
|
-
ensure_hmac_key_private_mode(key_path)
|
|
508
744
|
return key
|
|
509
745
|
|
|
510
746
|
|
|
@@ -532,10 +768,29 @@ def write_all(fd: int, data: bytes) -> None:
|
|
|
532
768
|
while total < len(data):
|
|
533
769
|
written = os.write(fd, view[total:])
|
|
534
770
|
if written <= 0:
|
|
535
|
-
raise OSError("short write to local
|
|
771
|
+
raise OSError("short write to local private file")
|
|
536
772
|
total += written
|
|
537
773
|
|
|
538
774
|
|
|
775
|
+
def lock_file_exclusive(fd: int, *, label: str) -> bool:
|
|
776
|
+
if fcntl is None:
|
|
777
|
+
fail(f"could not lock {label}: platform file locking unavailable")
|
|
778
|
+
try:
|
|
779
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
780
|
+
except OSError as exc:
|
|
781
|
+
fail(f"could not lock {label}: {os_error_detail(exc)}")
|
|
782
|
+
return True
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def unlock_file(fd: int) -> None:
|
|
786
|
+
if fcntl is None:
|
|
787
|
+
return
|
|
788
|
+
try:
|
|
789
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
790
|
+
except OSError:
|
|
791
|
+
pass
|
|
792
|
+
|
|
793
|
+
|
|
539
794
|
@dataclass(frozen=True)
|
|
540
795
|
class KeyLock:
|
|
541
796
|
nonce: str
|
|
@@ -543,20 +798,51 @@ class KeyLock:
|
|
|
543
798
|
|
|
544
799
|
|
|
545
800
|
def write_key_lock_metadata(lock_dir: Path) -> KeyLock:
|
|
801
|
+
reject_symlink_components(lock_dir, label="local HMAC key lock directory")
|
|
546
802
|
nonce = secrets.token_hex(8)
|
|
547
803
|
metadata = {
|
|
548
804
|
"pid": os.getpid(),
|
|
549
805
|
"created_at_unix": time.time(),
|
|
550
806
|
"nonce": nonce,
|
|
551
807
|
}
|
|
552
|
-
|
|
808
|
+
lock_fd = -1
|
|
809
|
+
fd = -1
|
|
553
810
|
try:
|
|
554
|
-
|
|
555
|
-
os.
|
|
556
|
-
|
|
811
|
+
lock_fd = open_private_directory(lock_dir, label="local HMAC key lock directory")
|
|
812
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | _no_follow_flag()
|
|
813
|
+
if hasattr(os, "O_CLOEXEC"):
|
|
814
|
+
flags |= os.O_CLOEXEC
|
|
815
|
+
fd = os.open(LOCK_OWNER_NAME, flags, 0o600, dir_fd=lock_fd)
|
|
816
|
+
st = os.fstat(fd)
|
|
817
|
+
if not stat.S_ISREG(st.st_mode):
|
|
818
|
+
fail("local HMAC key lock metadata must be a regular file")
|
|
819
|
+
try:
|
|
820
|
+
os.fchmod(fd, 0o600)
|
|
821
|
+
except (AttributeError, OSError):
|
|
822
|
+
pass
|
|
823
|
+
st = os.fstat(fd)
|
|
824
|
+
if os.name == "posix" and stat.S_IMODE(st.st_mode) != 0o600:
|
|
825
|
+
fail("could not verify local HMAC key lock metadata privacy: expected mode 0600")
|
|
826
|
+
write_all(fd, json_bytes(metadata).encode("utf-8"))
|
|
827
|
+
write_all(fd, b"\n")
|
|
828
|
+
os.fsync(fd)
|
|
829
|
+
os.close(fd)
|
|
830
|
+
fd = -1
|
|
831
|
+
fsync_directory_fd(lock_fd)
|
|
557
832
|
return KeyLock(nonce=nonce, metadata_written=True)
|
|
558
833
|
except OSError:
|
|
559
834
|
return KeyLock(nonce=nonce, metadata_written=False)
|
|
835
|
+
finally:
|
|
836
|
+
if fd >= 0:
|
|
837
|
+
try:
|
|
838
|
+
os.close(fd)
|
|
839
|
+
except OSError:
|
|
840
|
+
pass
|
|
841
|
+
if lock_fd >= 0:
|
|
842
|
+
try:
|
|
843
|
+
os.close(lock_fd)
|
|
844
|
+
except OSError:
|
|
845
|
+
pass
|
|
560
846
|
|
|
561
847
|
|
|
562
848
|
def key_lock_age_seconds(lock_dir: Path, now: float | None = None) -> float:
|
|
@@ -694,6 +980,7 @@ def acquire_key_lock(lock_dir: Path, key_path: Path) -> KeyLock | None:
|
|
|
694
980
|
|
|
695
981
|
|
|
696
982
|
def load_or_create_hmac_key(store_dir: Path) -> bytes:
|
|
983
|
+
store_dir = normalize_allowed_first_absolute_symlink(store_dir)
|
|
697
984
|
ensure_private_dir(store_dir)
|
|
698
985
|
cleanup_orphaned_stale_key_locks(store_dir)
|
|
699
986
|
key_path = store_dir / KEY_NAME
|
|
@@ -705,25 +992,38 @@ def load_or_create_hmac_key(store_dir: Path) -> bytes:
|
|
|
705
992
|
if locked is None:
|
|
706
993
|
return read_hmac_key(key_path)
|
|
707
994
|
|
|
708
|
-
|
|
995
|
+
store_fd = -1
|
|
996
|
+
tmp_leaf: str | None = None
|
|
709
997
|
try:
|
|
710
998
|
if key_path.exists():
|
|
711
999
|
return read_hmac_key(key_path)
|
|
712
1000
|
key = secrets.token_bytes(32)
|
|
713
1001
|
encoded = base64.urlsafe_b64encode(key)
|
|
714
|
-
|
|
1002
|
+
store_fd = open_private_directory(store_dir, label="local HMAC ledger directory")
|
|
1003
|
+
tmp_leaf = f"{KEY_NAME}.{os.getpid()}.{secrets.token_hex(8)}.tmp"
|
|
715
1004
|
try:
|
|
716
|
-
|
|
1005
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | _no_follow_flag()
|
|
1006
|
+
if hasattr(os, "O_CLOEXEC"):
|
|
1007
|
+
flags |= os.O_CLOEXEC
|
|
1008
|
+
fd = os.open(tmp_leaf, flags, 0o600, dir_fd=store_fd)
|
|
717
1009
|
except OSError as exc:
|
|
718
1010
|
fail(f"could not create local HMAC key file: {os_error_detail(exc)}")
|
|
719
1011
|
close_error: OSError | None = None
|
|
720
1012
|
try:
|
|
1013
|
+
st = os.fstat(fd)
|
|
1014
|
+
if not stat.S_ISREG(st.st_mode):
|
|
1015
|
+
fail("local HMAC key file must be a regular file")
|
|
721
1016
|
try:
|
|
722
1017
|
os.fchmod(fd, 0o600)
|
|
723
1018
|
except (AttributeError, OSError):
|
|
724
1019
|
pass
|
|
1020
|
+
st = os.fstat(fd)
|
|
1021
|
+
if os.name == "posix" and stat.S_IMODE(st.st_mode) != 0o600:
|
|
1022
|
+
fail("could not verify local HMAC key file privacy: expected mode 0600")
|
|
725
1023
|
write_all(fd, encoded)
|
|
726
1024
|
os.fsync(fd)
|
|
1025
|
+
except CostGuardError:
|
|
1026
|
+
raise
|
|
727
1027
|
except OSError as exc:
|
|
728
1028
|
fail(f"could not write local HMAC key file: {os_error_detail(exc)}")
|
|
729
1029
|
finally:
|
|
@@ -733,25 +1033,34 @@ def load_or_create_hmac_key(store_dir: Path) -> bytes:
|
|
|
733
1033
|
close_error = exc
|
|
734
1034
|
if close_error is not None:
|
|
735
1035
|
fail(f"could not write local HMAC key file: {os_error_detail(close_error)}")
|
|
736
|
-
ensure_hmac_key_private_mode(tmp_path)
|
|
737
1036
|
if locked.metadata_written and not key_lock_owner_matches(lock_dir, locked):
|
|
738
1037
|
if key_path.exists():
|
|
739
1038
|
return read_hmac_key(key_path)
|
|
740
1039
|
fail("lost local HMAC key lock; retry")
|
|
741
1040
|
try:
|
|
742
|
-
os.replace(
|
|
1041
|
+
os.replace(tmp_leaf, KEY_NAME, src_dir_fd=store_fd, dst_dir_fd=store_fd)
|
|
1042
|
+
except TypeError:
|
|
1043
|
+
fail("could not persist local HMAC key file: platform dir_fd replace unavailable")
|
|
743
1044
|
except OSError as exc:
|
|
744
1045
|
fail(f"could not persist local HMAC key file: {os_error_detail(exc)}")
|
|
745
|
-
|
|
746
|
-
|
|
1046
|
+
tmp_leaf = None
|
|
1047
|
+
fsync_directory_fd(store_fd)
|
|
747
1048
|
# Re-read the persisted file so callers always use the same bytes future
|
|
748
1049
|
# ledger lookups will use. The lock prevents first-use races without
|
|
749
1050
|
# relying on hard links or replacing another process's winner key.
|
|
750
1051
|
return read_hmac_key(key_path)
|
|
751
1052
|
finally:
|
|
752
|
-
if
|
|
1053
|
+
if tmp_leaf is not None:
|
|
1054
|
+
try:
|
|
1055
|
+
if store_fd >= 0:
|
|
1056
|
+
os.unlink(tmp_leaf, dir_fd=store_fd)
|
|
1057
|
+
else:
|
|
1058
|
+
(store_dir / tmp_leaf).unlink()
|
|
1059
|
+
except OSError:
|
|
1060
|
+
pass
|
|
1061
|
+
if store_fd >= 0:
|
|
753
1062
|
try:
|
|
754
|
-
|
|
1063
|
+
os.close(store_fd)
|
|
755
1064
|
except OSError:
|
|
756
1065
|
pass
|
|
757
1066
|
cleanup_key_lock(lock_dir, locked)
|
|
@@ -765,42 +1074,145 @@ def ledger_path(store_dir: Path) -> Path:
|
|
|
765
1074
|
return store_dir / LEDGER_NAME
|
|
766
1075
|
|
|
767
1076
|
|
|
1077
|
+
def parse_ledger_line(raw_line: bytes) -> dict[str, Any] | None:
|
|
1078
|
+
try:
|
|
1079
|
+
line = raw_line.decode("utf-8").strip()
|
|
1080
|
+
except UnicodeDecodeError:
|
|
1081
|
+
return None
|
|
1082
|
+
if not line:
|
|
1083
|
+
return None
|
|
1084
|
+
try:
|
|
1085
|
+
row = json.loads(line, parse_constant=reject_json_constant)
|
|
1086
|
+
except (json.JSONDecodeError, ValueError):
|
|
1087
|
+
return None
|
|
1088
|
+
if isinstance(row, dict):
|
|
1089
|
+
return row
|
|
1090
|
+
return None
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def parse_ledger_lines(raw_lines: list[bytes]) -> list[dict[str, Any]]:
|
|
1094
|
+
rows: list[dict[str, Any]] = []
|
|
1095
|
+
for raw_line in raw_lines:
|
|
1096
|
+
row = parse_ledger_line(raw_line)
|
|
1097
|
+
if row is not None:
|
|
1098
|
+
rows.append(row)
|
|
1099
|
+
return rows
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def tail_recent_ledger_rows(handle, *, initial_bytes: int, max_rows: int) -> list[dict[str, Any]]:
|
|
1103
|
+
if max_rows <= 0:
|
|
1104
|
+
return []
|
|
1105
|
+
handle.seek(0, os.SEEK_END)
|
|
1106
|
+
size = handle.tell()
|
|
1107
|
+
if size <= 0:
|
|
1108
|
+
return []
|
|
1109
|
+
window = max(1, int(initial_bytes))
|
|
1110
|
+
while True:
|
|
1111
|
+
start = max(0, size - window)
|
|
1112
|
+
handle.seek(start)
|
|
1113
|
+
data = handle.read(size - start)
|
|
1114
|
+
if start > 0:
|
|
1115
|
+
newline_at = data.find(b"\n")
|
|
1116
|
+
if newline_at < 0:
|
|
1117
|
+
candidate_lines: list[bytes] = []
|
|
1118
|
+
else:
|
|
1119
|
+
candidate_lines = data[newline_at + 1 :].split(b"\n")
|
|
1120
|
+
else:
|
|
1121
|
+
candidate_lines = data.split(b"\n")
|
|
1122
|
+
rows = parse_ledger_lines(candidate_lines)
|
|
1123
|
+
if start == 0:
|
|
1124
|
+
return rows[-max_rows:]
|
|
1125
|
+
if len(rows) >= max_rows:
|
|
1126
|
+
return rows[-max_rows:]
|
|
1127
|
+
window = min(size, window * 2)
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
def open_private_regular_file_for_append(path: Path, *, label: str) -> int:
|
|
1131
|
+
path = normalize_allowed_first_absolute_symlink(path)
|
|
1132
|
+
leaf_name = _private_leaf_name(path, label=label)
|
|
1133
|
+
try:
|
|
1134
|
+
if path.is_symlink():
|
|
1135
|
+
fail(f"{label} must not be a symlink")
|
|
1136
|
+
except OSError as exc:
|
|
1137
|
+
fail(f"could not inspect {label}: {os_error_detail(exc)}")
|
|
1138
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND | _no_follow_flag()
|
|
1139
|
+
if hasattr(os, "O_CLOEXEC"):
|
|
1140
|
+
flags |= os.O_CLOEXEC
|
|
1141
|
+
parent_fd = -1
|
|
1142
|
+
fd = -1
|
|
1143
|
+
try:
|
|
1144
|
+
parent_fd = open_private_directory(path.parent, label=f"{label} parent")
|
|
1145
|
+
fd = os.open(leaf_name, flags, 0o600, dir_fd=parent_fd)
|
|
1146
|
+
st = os.fstat(fd)
|
|
1147
|
+
if not stat.S_ISREG(st.st_mode):
|
|
1148
|
+
fail(f"{label} must be a regular file")
|
|
1149
|
+
try:
|
|
1150
|
+
os.fchmod(fd, 0o600)
|
|
1151
|
+
except (AttributeError, OSError):
|
|
1152
|
+
pass
|
|
1153
|
+
st = os.fstat(fd)
|
|
1154
|
+
if os.name == "posix" and stat.S_IMODE(st.st_mode) != 0o600:
|
|
1155
|
+
fail(f"could not verify {label} privacy: expected mode 0600")
|
|
1156
|
+
owned_fd = fd
|
|
1157
|
+
fd = -1
|
|
1158
|
+
return owned_fd
|
|
1159
|
+
except CostGuardError:
|
|
1160
|
+
raise
|
|
1161
|
+
except OSError as exc:
|
|
1162
|
+
fail(f"could not open {label}: {os_error_detail(exc)}")
|
|
1163
|
+
finally:
|
|
1164
|
+
if fd >= 0:
|
|
1165
|
+
# Ownership transfers to the caller only on the successful return
|
|
1166
|
+
# above. On errors, close before surfacing a deterministic message.
|
|
1167
|
+
try:
|
|
1168
|
+
os.close(fd)
|
|
1169
|
+
except OSError:
|
|
1170
|
+
pass
|
|
1171
|
+
if parent_fd >= 0:
|
|
1172
|
+
try:
|
|
1173
|
+
os.close(parent_fd)
|
|
1174
|
+
except OSError:
|
|
1175
|
+
pass
|
|
1176
|
+
|
|
1177
|
+
|
|
768
1178
|
def load_ledger(store_dir: Path) -> list[dict[str, Any]]:
|
|
1179
|
+
store_dir = normalize_allowed_first_absolute_symlink(store_dir)
|
|
769
1180
|
path = ledger_path(store_dir)
|
|
770
1181
|
if not path.exists():
|
|
771
1182
|
return []
|
|
772
1183
|
rows: list[dict[str, Any]] = []
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
1184
|
+
fd = open_private_regular_fd_for_read(path, label="local HMAC ledger file")
|
|
1185
|
+
try:
|
|
1186
|
+
with os.fdopen(fd, "rb") as fh:
|
|
1187
|
+
fd = -1
|
|
1188
|
+
rows = tail_recent_ledger_rows(fh, initial_bytes=LEDGER_TAIL_INITIAL_BYTES, max_rows=MAX_LEDGER_ROWS)
|
|
1189
|
+
finally:
|
|
1190
|
+
if fd >= 0:
|
|
778
1191
|
try:
|
|
779
|
-
|
|
780
|
-
except
|
|
781
|
-
|
|
782
|
-
if isinstance(row, dict):
|
|
783
|
-
rows.append(row)
|
|
1192
|
+
os.close(fd)
|
|
1193
|
+
except OSError:
|
|
1194
|
+
pass
|
|
784
1195
|
return rows[-MAX_LEDGER_ROWS:]
|
|
785
1196
|
|
|
786
1197
|
|
|
787
1198
|
def append_ledger(store_dir: Path, entry: dict[str, Any]) -> None:
|
|
1199
|
+
store_dir = normalize_allowed_first_absolute_symlink(store_dir)
|
|
788
1200
|
ensure_private_dir(store_dir)
|
|
789
1201
|
path = ledger_path(store_dir)
|
|
790
|
-
# JSONL is append-only.
|
|
791
|
-
#
|
|
792
|
-
# pre-existing malformed/partial line by
|
|
793
|
-
|
|
794
|
-
fd =
|
|
1202
|
+
# JSONL is append-only. Hold an advisory file lock while looping over
|
|
1203
|
+
# os.write so short writes do not interleave with cooperating local wrappers;
|
|
1204
|
+
# load_ledger also tolerates any pre-existing malformed/partial line by
|
|
1205
|
+
# skipping it.
|
|
1206
|
+
fd = open_private_regular_file_for_append(path, label="local HMAC ledger file")
|
|
1207
|
+
locked = False
|
|
795
1208
|
try:
|
|
796
|
-
|
|
1209
|
+
locked = lock_file_exclusive(fd, label="local HMAC ledger file")
|
|
1210
|
+
write_all(fd, (json_bytes(entry) + "\n").encode("utf-8"))
|
|
797
1211
|
os.fsync(fd)
|
|
798
1212
|
finally:
|
|
1213
|
+
if locked:
|
|
1214
|
+
unlock_file(fd)
|
|
799
1215
|
os.close(fd)
|
|
800
|
-
try:
|
|
801
|
-
os.chmod(path, 0o600)
|
|
802
|
-
except OSError:
|
|
803
|
-
pass
|
|
804
1216
|
|
|
805
1217
|
|
|
806
1218
|
def latest_fingerprint_rows(rows: list[dict[str, Any]]) -> dict[tuple[str, str], dict[str, Any]]:
|