@ictechgy/context-guard 0.4.15 → 0.5.1

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 (32) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.ko.md +128 -2
  3. package/README.md +144 -3
  4. package/docs/distribution.md +100 -0
  5. package/package.json +4 -1
  6. package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
  7. package/plugins/context-guard/README.ko.md +43 -1
  8. package/plugins/context-guard/README.md +44 -1
  9. package/plugins/context-guard/bin/bash_reference_policy.py +967 -0
  10. package/plugins/context-guard/bin/context-guard-artifact +90 -9
  11. package/plugins/context-guard/bin/context-guard-audit +169 -66
  12. package/plugins/context-guard/bin/context-guard-bench +9865 -211
  13. package/plugins/context-guard/bin/context-guard-compress +90 -8
  14. package/plugins/context-guard/bin/context-guard-diet +1 -7
  15. package/plugins/context-guard/bin/context-guard-experiments +5 -1
  16. package/plugins/context-guard/bin/context-guard-failed-nudge +777 -83
  17. package/plugins/context-guard/bin/context-guard-guard-read +496 -57
  18. package/plugins/context-guard/bin/context-guard-mcp +2 -1
  19. package/plugins/context-guard/bin/context-guard-pack +1570 -150
  20. package/plugins/context-guard/bin/context-guard-read-symbol +7 -2
  21. package/plugins/context-guard/bin/context-guard-rewrite-bash +2669 -236
  22. package/plugins/context-guard/bin/context-guard-sanitize-output +723 -92
  23. package/plugins/context-guard/bin/context-guard-setup +1944 -222
  24. package/plugins/context-guard/bin/context-guard-statusline +163 -55
  25. package/plugins/context-guard/bin/context-guard-statusline-merged +78 -23
  26. package/plugins/context-guard/bin/context-guard-tool-prune +44 -11
  27. package/plugins/context-guard/bin/context-guard-trim-output +795 -48
  28. package/plugins/context-guard/brief/README.md +19 -0
  29. package/plugins/context-guard/brief/narration-mode.quiet.md +21 -0
  30. package/plugins/context-guard/lib/context_guard_commands.py +10 -2
  31. package/plugins/context-guard/lib/credential_policy.py +185 -0
  32. package/plugins/context-guard/lib/transcript_usage_reducer.py +378 -0
@@ -8,6 +8,7 @@ remain supported for existing project settings.
8
8
  """
9
9
  from __future__ import annotations
10
10
 
11
+ import copy
11
12
  import errno
12
13
  import hashlib
13
14
  import importlib.util
@@ -18,8 +19,9 @@ import secrets
18
19
  import shlex
19
20
  import stat
20
21
  import sys
22
+ import time
21
23
  from pathlib import Path
22
- from typing import Any
24
+ from typing import Any, NamedTuple
23
25
 
24
26
  SCRIPT_DIR = Path(__file__).resolve().parent
25
27
 
@@ -52,13 +54,25 @@ OUTLINE_MAX_BYTES = 200_000
52
54
  OUTLINE_MAX_ITEMS = 12
53
55
  READ_GUARD_STATE_DIR = Path(".context-guard")
54
56
  READ_GUARD_STATE_FILE = "read-guard-cache.json"
55
- READ_GUARD_STATE_MAX_ITEMS = 20
57
+ READ_GUARD_STATE_MAX_ITEMS = 128
56
58
  GUARD_ENV = "CONTEXT_GUARD_READ_GUARD"
57
59
  LEGACY_GUARD_ENV = "CLAUDE_TOKEN_READ_GUARD"
58
60
  MAX_BYTES_ENV = "CONTEXT_GUARD_READ_GUARD_MAX_BYTES"
59
61
  LEGACY_MAX_BYTES_ENV = "CLAUDE_TOKEN_READ_GUARD_MAX_BYTES"
60
62
  MAX_LINE_RANGE_ENV = "CONTEXT_GUARD_READ_GUARD_MAX_LINES"
61
63
  LEGACY_MAX_LINE_RANGE_ENV = "CLAUDE_TOKEN_READ_GUARD_MAX_LINES"
64
+ READ_PROOF_BYTES_ENV = "CONTEXT_GUARD_READ_GUARD_PROOF_BYTES"
65
+ LEGACY_READ_PROOF_BYTES_ENV = "CLAUDE_TOKEN_READ_GUARD_PROOF_BYTES"
66
+ DEFAULT_READ_PROOF_BYTES = 8 * 1024 * 1024
67
+ MIN_READ_PROOF_BYTES = 64 * 1024
68
+ MAX_READ_PROOF_BYTES = 64 * 1024 * 1024
69
+ READ_PROOF_CHUNK_BYTES = 64 * 1024
70
+ MAX_READ_RANGE_INTEGER = (1 << 63) - 1
71
+ ALLOWED_ENV_TEMPLATE_BASENAMES = frozenset({
72
+ ".env.example",
73
+ ".env.sample",
74
+ ".env.template",
75
+ })
62
76
  PATH_LABEL_MAX_CHARS = 160
63
77
  ALLOWED_FIRST_ABSOLUTE_SYMLINKS = {
64
78
  "tmp": Path("/private/tmp"),
@@ -110,6 +124,16 @@ def max_line_range() -> int:
110
124
  )
111
125
 
112
126
 
127
+ def read_proof_bytes() -> int:
128
+ return bounded_env_int(
129
+ READ_PROOF_BYTES_ENV,
130
+ LEGACY_READ_PROOF_BYTES_ENV,
131
+ DEFAULT_READ_PROOF_BYTES,
132
+ MIN_READ_PROOF_BYTES,
133
+ MAX_READ_PROOF_BYTES,
134
+ )
135
+
136
+
113
137
  def tool_input(payload: dict[str, Any]) -> dict[str, Any]:
114
138
  value = payload.get("tool_input") or payload.get("toolInput") or {}
115
139
  return value if isinstance(value, dict) else {}
@@ -145,25 +169,48 @@ def anonymized_path_label(path: Path) -> str:
145
169
  return f"redacted-path#path:{digest}"
146
170
 
147
171
 
148
- def bounded_line_range_requested(payload: dict[str, Any]) -> bool:
149
- data = tool_input(payload)
150
- raw_limit = data.get("limit")
151
- if raw_limit is None:
152
- return False
172
+ def strict_integer(value: object) -> int | None:
173
+ if isinstance(value, bool):
174
+ return None
175
+ if isinstance(value, int):
176
+ return value
177
+ if not isinstance(value, str):
178
+ return None
179
+ normalized = value.strip()
180
+ if not re.fullmatch(r"[+-]?[0-9]+", normalized):
181
+ return None
153
182
  try:
154
- limit = int(raw_limit)
155
- except (TypeError, ValueError):
156
- return False
183
+ return int(normalized)
184
+ except (TypeError, ValueError, OverflowError):
185
+ return None
186
+
187
+
188
+ def large_read_range(payload: dict[str, Any]) -> tuple[int, int] | None:
189
+ data = tool_input(payload)
190
+ limit = strict_integer(data.get("limit"))
191
+ if limit is None:
192
+ return None
157
193
  if limit <= 0 or limit > max_line_range():
158
- return False
159
- raw_offset = data.get("offset")
160
- if raw_offset is not None:
161
- try:
162
- if int(raw_offset) < 0:
163
- return False
164
- except (TypeError, ValueError):
165
- return False
166
- return True
194
+ return None
195
+ raw_offset = data.get("offset", 0)
196
+ offset = strict_integer(raw_offset)
197
+ if offset is None or offset < 0 or offset > MAX_READ_RANGE_INTEGER:
198
+ return None
199
+ if limit > MAX_READ_RANGE_INTEGER - offset:
200
+ return None
201
+ return offset, limit
202
+
203
+
204
+ def bounded_line_range_requested(payload: dict[str, Any]) -> bool:
205
+ return large_read_range(payload) is not None
206
+
207
+
208
+ def read_env_file_denied(path: Path) -> bool:
209
+ basename = path.name
210
+ return (
211
+ basename.casefold().startswith(".env")
212
+ and basename not in ALLOWED_ENV_TEMPLATE_BASENAMES
213
+ )
167
214
 
168
215
 
169
216
  def safe_label(path: Path, root: Path) -> str:
@@ -362,12 +409,124 @@ def regular_file_size_no_symlink(path: Path) -> int:
362
409
  os.close(fd)
363
410
 
364
411
 
412
+ class ReadRangeProof(NamedTuple):
413
+ outcome: str
414
+ charged_bytes: int
415
+ scanned_bytes: int
416
+
417
+
418
+ def stat_identity(stat_result: os.stat_result) -> tuple[int, int, int, int]:
419
+ mtime_ns = getattr(
420
+ stat_result,
421
+ "st_mtime_ns",
422
+ int(stat_result.st_mtime * 1_000_000_000),
423
+ )
424
+ return (
425
+ stat_result.st_dev,
426
+ stat_result.st_ino,
427
+ stat_result.st_size,
428
+ mtime_ns,
429
+ )
430
+
431
+
432
+ def prove_raw_read_range(
433
+ fd: int,
434
+ *,
435
+ file_size: int,
436
+ offset: int,
437
+ limit: int,
438
+ content_budget: int,
439
+ proof_budget: int,
440
+ ) -> ReadRangeProof:
441
+ """Prove a zero-based logical-line range from raw bytes on one open fd."""
442
+ if (
443
+ file_size < 0
444
+ or offset < 0
445
+ or limit <= 0
446
+ or content_budget < 0
447
+ or proof_budget <= 0
448
+ or offset > MAX_READ_RANGE_INTEGER
449
+ or limit > MAX_READ_RANGE_INTEGER - offset
450
+ ):
451
+ raise ValueError("invalid raw Read proof parameters")
452
+
453
+ selected_end = offset + limit
454
+ line_index = 0
455
+ charged_bytes = 0
456
+ scanned_bytes = 0
457
+ os.lseek(fd, 0, os.SEEK_SET)
458
+
459
+ while scanned_bytes < file_size and scanned_bytes < proof_budget:
460
+ remaining = min(
461
+ READ_PROOF_CHUNK_BYTES,
462
+ file_size - scanned_bytes,
463
+ proof_budget - scanned_bytes,
464
+ )
465
+ chunk = os.read(fd, remaining)
466
+ if not chunk:
467
+ return ReadRangeProof("file_changed_during_proof", charged_bytes, scanned_bytes)
468
+ for byte in chunk:
469
+ scanned_bytes += 1
470
+ if byte == 0x0A:
471
+ line_index += 1
472
+ if line_index >= selected_end:
473
+ return ReadRangeProof("allowed", charged_bytes, scanned_bytes)
474
+ continue
475
+ if offset <= line_index < selected_end:
476
+ charged_bytes += 1
477
+ if charged_bytes > content_budget:
478
+ return ReadRangeProof("content_budget_exceeded", charged_bytes, scanned_bytes)
479
+
480
+ if scanned_bytes >= file_size:
481
+ return ReadRangeProof("allowed", charged_bytes, scanned_bytes)
482
+ return ReadRangeProof("proof_budget_exhausted", charged_bytes, scanned_bytes)
483
+
484
+
485
+ def raw_read_range_outcome(
486
+ fd: int,
487
+ initial_stat: os.stat_result,
488
+ *,
489
+ size: int,
490
+ offset: int,
491
+ limit: int,
492
+ content_limit: int,
493
+ ) -> str:
494
+ """지정된 offset/limit로 raw-byte 증명을 수행하고 동일 fd의 정체성 변화까지 확인한다.
495
+
496
+ peek/commit 분리 설계의 핵심: 밸브 판정과 증명이 fd 보유 구간 안에서만 일어나며
497
+ 별도로 재개방하지 않으므로 새 TOCTOU 창을 만들지 않는다.
498
+ """
499
+ try:
500
+ proof = prove_raw_read_range(
501
+ fd,
502
+ file_size=size,
503
+ offset=offset,
504
+ limit=limit,
505
+ content_budget=content_limit,
506
+ proof_budget=read_proof_bytes(),
507
+ )
508
+ outcome = proof.outcome
509
+ except (OSError, ValueError):
510
+ outcome = "read_proof_failed"
511
+ try:
512
+ final_stat = os.fstat(fd)
513
+ except OSError:
514
+ return "file_changed_during_proof"
515
+ if stat_identity(initial_stat) != stat_identity(final_stat):
516
+ return "file_changed_during_proof"
517
+ return outcome
518
+
519
+
365
520
  def find_read_symbol_command() -> str:
366
521
  script_dir = Path(__file__).resolve().parent
367
522
  if (script_dir / "context-guard-read-symbol").exists():
368
- return "context-guard-read-symbol"
523
+ return shlex.join(
524
+ [os.path.realpath(sys.executable), "-I", str((script_dir / "context-guard-read-symbol").resolve())]
525
+ )
369
526
  if (script_dir / "read_symbol.py").exists():
370
- return "python3 context-guard-kit/read_symbol.py"
527
+ return shlex.join(
528
+ [os.path.realpath(sys.executable), "-I", str((script_dir / "read_symbol.py").resolve())]
529
+ )
371
530
  return "context-guard-read-symbol"
372
531
 
373
532
 
@@ -473,10 +632,19 @@ def line_estimate(prefix: str, size: int, truncated: bool) -> str:
473
632
  return f"~{estimated} (estimated from first {lines})"
474
633
 
475
634
 
476
- def progressive_read_ladder(path: Path, label: str, size: int, limit: int, read_symbol: str) -> str:
635
+ def progressive_read_ladder(
636
+ path: Path,
637
+ label: str,
638
+ size: int,
639
+ limit: int,
640
+ read_symbol: str,
641
+ *,
642
+ command_path: str | None = None,
643
+ ) -> str:
477
644
  prefix, prefix_truncated = read_prefix_for_outline(path)
478
645
  items = outline_items(path, prefix)
479
- rg_cmd, symbol_cmd = suggested_commands(label, read_symbol)
646
+ actionable_path = command_path if command_path is not None else label
647
+ rg_cmd, symbol_cmd = suggested_commands(actionable_path, read_symbol)
480
648
  range_limit = min(max_line_range(), 120)
481
649
  parts = [
482
650
  f"[context-guard-kit] Large Read blocked for {label} ({size} bytes > {limit} byte guard).",
@@ -485,7 +653,7 @@ def progressive_read_ladder(path: Path, label: str, size: int, limit: int, read_
485
653
  ]
486
654
  if items:
487
655
  first_name = items[0].split(" ", 3)[-1].split(" ", 1)[-1]
488
- read_parts = shlex.split(read_symbol) + [label, first_name]
656
+ read_parts = shlex.split(read_symbol) + [actionable_path, first_name]
489
657
  parts.append(f"2) Read a symbol slice: `{shlex.join(read_parts)}` (or `{symbol_cmd}`)")
490
658
  else:
491
659
  parts.append(f"2) Read a symbol slice when you know the name: `{symbol_cmd}`")
@@ -501,12 +669,111 @@ def progressive_read_ladder(path: Path, label: str, size: int, limit: int, read_
501
669
  return " ".join(parts)
502
670
 
503
671
 
504
- def read_guard_fingerprint(path: Path, label: str, size: int) -> str:
672
+ def project_relative_path(path: Path, root: Path) -> str | None:
505
673
  try:
506
- stat_result = path.stat()
507
- mtime = getattr(stat_result, "st_mtime_ns", int(stat_result.st_mtime * 1_000_000_000))
508
- except OSError:
674
+ normalized_path = Path(os.path.abspath(os.fspath(path)))
675
+ normalized_root = Path(os.path.abspath(os.fspath(root)))
676
+ return normalized_path.relative_to(normalized_root).as_posix()
677
+ except (OSError, ValueError):
678
+ return None
679
+
680
+
681
+ def project_relative_command_path(path: Path, root: Path) -> str | None:
682
+ relative = project_relative_path(path, root)
683
+ if relative is None:
684
+ return None
685
+ if (
686
+ not relative
687
+ or len(relative) > PATH_LABEL_MAX_CHARS
688
+ or CONTROL_CHAR_RE.search(relative)
689
+ or hook_label_has_sensitive_evidence(relative)
690
+ ):
691
+ return None
692
+ return relative
693
+
694
+
695
+ def read_proof_denial_reason(
696
+ outcome: str,
697
+ *,
698
+ path: Path,
699
+ root: Path,
700
+ size: int,
701
+ content_limit: int,
702
+ read_symbol: str,
703
+ ) -> str:
704
+ relative_project_path = project_relative_path(path, root)
705
+ relative_path = project_relative_command_path(path, root)
706
+ if outcome == "file_changed_during_proof":
707
+ if relative_project_path is None:
708
+ target = "an out-of-project file"
709
+ elif relative_path is None:
710
+ target = safe_label(path, root)
711
+ else:
712
+ target = f"project file `{shlex.quote(relative_path)}`"
713
+ return (
714
+ f"[context-guard-kit] Read blocked for {target}: file_changed_during_proof. "
715
+ "The same open file changed identity, size, or modification time during the bounded proof. "
716
+ "Stabilize the file and retry with a smaller positive limit. A later Read uses a separate open, "
717
+ "so replacement after this hook returns remains a TOCTOU limitation."
718
+ )
719
+
720
+ outcome_detail = {
721
+ "invalid_read_range": (
722
+ "Large files require a positive integer limit within the configured maximum and a "
723
+ "zero-based, nonnegative, nonoverflowing integer offset."
724
+ ),
725
+ "proof_budget_exhausted": (
726
+ "The guard could not prove the requested start/end boundary or EOF within the raw-byte proof budget."
727
+ ),
728
+ "content_budget_exceeded": (
729
+ "The selected logical-line content exceeds the byte guard; LF terminators are not charged, "
730
+ "but CR and EOF-final content bytes are."
731
+ ),
732
+ }.get(outcome, "The bounded raw-byte Read proof could not safely allow this request.")
733
+
734
+ if relative_project_path is None:
735
+ return (
736
+ f"[context-guard-kit] Large Read blocked for an out-of-project file "
737
+ f"({size} bytes > {content_limit} byte guard): {outcome}. {outcome_detail} "
738
+ "Use a smaller positive limit and lower zero-based offset, or first perform an explicitly "
739
+ "user-authorized path-visible operation. No executable path suggestion is emitted for path privacy."
740
+ )
741
+ if relative_path is None:
742
+ return (
743
+ f"[context-guard-kit] Large Read blocked for {safe_label(path, root)} "
744
+ f"({size} bytes > {content_limit} byte guard): {outcome}. {outcome_detail} "
745
+ "Use a smaller positive limit and lower zero-based offset. No executable path suggestion is emitted "
746
+ "because this project-relative path contains privacy-sensitive or non-command-safe bytes."
747
+ )
748
+
749
+ label = safe_label(path, root)
750
+ ladder = progressive_read_ladder(
751
+ path,
752
+ label,
753
+ size,
754
+ content_limit,
755
+ read_symbol,
756
+ command_path=relative_path,
757
+ )
758
+ return f"{ladder} Read proof outcome={outcome}. {outcome_detail}"
759
+
760
+
761
+ def read_guard_fingerprint(
762
+ path: Path,
763
+ label: str,
764
+ size: int,
765
+ *,
766
+ stat_result: os.stat_result | None = None,
767
+ ) -> str:
768
+ if stat_result is None:
769
+ try:
770
+ stat_result = path.stat()
771
+ except OSError:
772
+ stat_result = None
773
+ if stat_result is None:
509
774
  mtime = 0
775
+ else:
776
+ mtime = getattr(stat_result, "st_mtime_ns", int(stat_result.st_mtime * 1_000_000_000))
510
777
  basis = f"{label}\0{size}\0{mtime}"
511
778
  return hashlib.sha256(basis.encode("utf-8", errors="replace")).hexdigest()[:16]
512
779
 
@@ -568,26 +835,66 @@ def save_read_guard_state(root: Path, state: dict[str, Any]) -> None:
568
835
  return
569
836
 
570
837
 
571
- def record_read_guard_attempt(root: Path, fp: str) -> int:
838
+ def default_read_guard_entry() -> dict[str, Any]:
839
+ """캐시에 아직 없거나 손상된 지문에 사용할 기본 시도 엔트리."""
840
+ return {"count": 0, "valve_used": False, "first_seen": 0, "last_seen": 0}
841
+
842
+
843
+ def normalize_read_guard_entry(entry: Any) -> dict[str, Any]:
844
+ """레거시 `{"count": N}` 및 손상된 엔트리를 신규 스키마로 하위 호환 정규화한다."""
845
+ normalized = default_read_guard_entry()
846
+ if not isinstance(entry, dict):
847
+ return normalized
848
+ normalized["count"] = bounded_int(entry.get("count", 0), 0, 0, 1_000_000)
849
+ normalized["valve_used"] = bool(entry.get("valve_used", False))
850
+ normalized["first_seen"] = bounded_int(entry.get("first_seen", 0), 0, 0, MAX_READ_RANGE_INTEGER)
851
+ normalized["last_seen"] = bounded_int(entry.get("last_seen", 0), 0, 0, MAX_READ_RANGE_INTEGER)
852
+ return normalized
853
+
854
+
855
+ def peek_read_guard_attempt(root: Path, fp: str) -> dict[str, Any]:
856
+ """카운터를 증가시키지 않고 현재 지문의 시도 엔트리를 읽는다(밸브 판정 전용).
857
+
858
+ 밸브 발화 여부는 대상 fd 보유 구간 안에서 결정해야 하므로, 영속화(commit)와
859
+ 분리된 읽기 전용 조회로 둔다. 대상 파일의 fd는 건드리지 않는다.
860
+ """
861
+ state = load_read_guard_state(root)
862
+ attempts = state.get("attempts")
863
+ if not isinstance(attempts, dict):
864
+ return default_read_guard_entry()
865
+ return normalize_read_guard_entry(attempts.get(fp))
866
+
867
+
868
+ def record_read_guard_attempt(root: Path, fp: str, *, valve_fired: bool = False) -> int:
869
+ """시도 횟수를 1 증가시키고 병합 방식으로 영속화한다(commit 단계, fd 미보유 구간).
870
+
871
+ 기존 엔트리를 통째로 덮어쓰지 않고 필드를 병합해 valve_used/first_seen/last_seen을
872
+ 보존한다. pop 후 재삽입 순서를 유지해 초과분 축출을 LRU 유사하게 만든다.
873
+ valve_used 등 전체 엔트리 조회가 필요하면 별도로 peek_read_guard_attempt를 쓴다
874
+ (이 함수의 반환값은 호출부가 실제로 쓰는 count만 담는다).
875
+ """
572
876
  state = load_read_guard_state(root)
573
877
  attempts = state.get("attempts")
574
878
  if not isinstance(attempts, dict):
575
879
  attempts = {}
576
- entry = attempts.get(fp)
577
- if not isinstance(entry, dict):
578
- entry = {"count": 0}
579
- count = bounded_int(entry.get("count", 0), 0, 0, 1_000_000) + 1
880
+ entry = normalize_read_guard_entry(attempts.get(fp))
881
+ now = int(time.time())
882
+ entry["count"] += 1
883
+ entry["valve_used"] = entry["valve_used"] or valve_fired
884
+ entry["first_seen"] = entry["first_seen"] or now
885
+ entry["last_seen"] = now
580
886
  attempts.pop(fp, None)
581
- attempts[fp] = {"count": count}
887
+ attempts[fp] = entry
582
888
  if len(attempts) > READ_GUARD_STATE_MAX_ITEMS:
583
889
  for key in list(attempts)[: len(attempts) - READ_GUARD_STATE_MAX_ITEMS]:
584
890
  attempts.pop(key, None)
585
891
  state["attempts"] = attempts
586
892
  save_read_guard_state(root, state)
587
- return count
893
+ return entry["count"]
588
894
 
589
895
 
590
896
  def repeated_read_hint(count: int) -> str:
897
+ """1~2회차 사다리 거부에 덧붙이는 반복 신호 문구(3회차부터는 단축 메시지를 대신 쓴다)."""
591
898
  if count < 2:
592
899
  return ""
593
900
  return (
@@ -596,6 +903,30 @@ def repeated_read_hint(count: int) -> str:
596
903
  )
597
904
 
598
905
 
906
+ def valve_exhausted_reason(count: int) -> str:
907
+ """지문이 과거에 실제로 밸브를 1회 발화(valve_used=True)한 뒤 다시 차단됐을 때 쓰는
908
+ 200바이트 미만 단축 메시지. "발화했다가 소진됨"을 뜻하므로 발화한 적이 없는
909
+ 지문에는 절대 쓰지 않는다 — 그 경우는 valve_budget_exceeded_reason을 쓴다.
910
+ """
911
+ return (
912
+ f"[context-guard-kit] Read blocked ({count}x, escape valve exhausted). "
913
+ "Use a smaller offset/limit range for this file."
914
+ )
915
+
916
+
917
+ def valve_budget_exceeded_reason(count: int, content_limit: int) -> str:
918
+ """좁힌 기본 범위(offset=0, limit=max_line_range())조차 예산을 넘어 밸브가 구조적으로
919
+ 발화할 수 없는 지문(minified/JSON/CSV/로그처럼 줄이 긴 파일)에 쓰는 200바이트 미만
920
+ 단축 메시지. "탈진"이라 말하지 않는다 — 애초에 켜진 적이 없기 때문이다. 실제 예산
921
+ 수치를 실어 에이전트가 스스로 유효한 offset/limit을 계산할 수 있게 한다.
922
+ """
923
+ return (
924
+ f"[context-guard-kit] Large Read blocked ({count}x). Narrowed {max_line_range()}-line "
925
+ f"range still exceeds the {content_limit:,}-byte guard; supply an explicit offset/limit "
926
+ "under it."
927
+ )
928
+
929
+
599
930
  def deny_response(reason: str) -> dict[str, Any]:
600
931
  return {
601
932
  "hookSpecificOutput": {
@@ -606,6 +937,18 @@ def deny_response(reason: str) -> dict[str, Any]:
606
937
  }
607
938
 
608
939
 
940
+ def valve_updated_input_response(payload: dict[str, Any], offer: tuple[int, int]) -> dict[str, Any]:
941
+ """3회차 밸브가 발화했을 때 offset/limit을 주입한 updatedInput 훅 응답을 만든다."""
942
+ updated_input = copy.deepcopy(tool_input(payload))
943
+ updated_input["offset"], updated_input["limit"] = offer
944
+ return {
945
+ "hookSpecificOutput": {
946
+ "hookEventName": "PreToolUse",
947
+ "updatedInput": updated_input,
948
+ }
949
+ }
950
+
951
+
609
952
  def main() -> int:
610
953
  if any(arg in {"-h", "--help"} for arg in sys.argv[1:]):
611
954
  print("ContextGuard helper: context-guard-guard-read")
@@ -634,11 +977,21 @@ def main() -> int:
634
977
  print("{}")
635
978
  return 0
636
979
  root = Path.cwd().resolve()
637
- path = Path(raw_path).expanduser()
638
- if not path.is_absolute():
639
- path = root / path
640
- path = normalize_allowed_first_absolute_symlink(path)
641
- if has_symlink_component(path):
980
+ try:
981
+ path = Path(raw_path).expanduser()
982
+ if not path.is_absolute():
983
+ path = root / path
984
+ path = Path(os.path.abspath(os.fspath(path)))
985
+ path = normalize_allowed_first_absolute_symlink(path)
986
+ traverses_symlink = has_symlink_component(path)
987
+ except (OSError, RuntimeError, ValueError):
988
+ reason = (
989
+ "[context-guard-kit] Read blocked because the requested file path could not be normalized safely. "
990
+ "Retry with a valid, explicit file path."
991
+ )
992
+ print(json.dumps(deny_response(reason), ensure_ascii=False))
993
+ return 0
994
+ if traverses_symlink:
642
995
  label = safe_label(path, root)
643
996
  reason = (
644
997
  f"[context-guard-kit] Read blocked for {label}: requested path traverses a symlink. "
@@ -646,10 +999,64 @@ def main() -> int:
646
999
  )
647
1000
  print(json.dumps(deny_response(reason), ensure_ascii=False))
648
1001
  return 0
1002
+ if read_env_file_denied(path):
1003
+ reason = (
1004
+ "[context-guard-kit] Read blocked by the Read-only environment-file policy: the normalized basename "
1005
+ "begins with .env and is not exactly .env.example, .env.sample, or .env.template. "
1006
+ "This hook protects Claude Read only; Glob name listings, Grep, and Bash/process access are out of scope."
1007
+ )
1008
+ print(json.dumps(deny_response(reason), ensure_ascii=False))
1009
+ return 0
1010
+ content_limit = max_bytes()
1011
+ size = 0
1012
+ initial_stat: os.stat_result | None = None
1013
+ outcome = "invalid_read_range"
1014
+ fingerprint = ""
1015
+ valve_offer: tuple[int, int] | None = None
1016
+ fd = -1
649
1017
  try:
650
- size = regular_file_size_no_symlink(path)
651
- except OSError as exc:
652
- if exc.errno == errno.ELOOP:
1018
+ fd = open_regular_no_symlink(path)
1019
+ initial_stat = os.fstat(fd)
1020
+ size = initial_stat.st_size
1021
+ if size <= content_limit:
1022
+ print("{}")
1023
+ return 0
1024
+
1025
+ # peek: fd 보유 구간 안에서 밸브 판정에 쓸 이전 시도 횟수를 읽는다(쓰기 없음).
1026
+ fingerprint = read_guard_fingerprint(path, safe_label(path, root), size, stat_result=initial_stat)
1027
+ attempt_peek = peek_read_guard_attempt(root, fingerprint)
1028
+
1029
+ requested_range = large_read_range(payload)
1030
+ if requested_range is not None:
1031
+ outcome = raw_read_range_outcome(
1032
+ fd,
1033
+ initial_stat,
1034
+ size=size,
1035
+ offset=requested_range[0],
1036
+ limit=requested_range[1],
1037
+ content_limit=content_limit,
1038
+ )
1039
+
1040
+ if (
1041
+ outcome not in ("allowed", "file_changed_during_proof")
1042
+ and attempt_peek["count"] + 1 == 3
1043
+ and not attempt_peek["valve_used"]
1044
+ ):
1045
+ candidate = (0, max_line_range())
1046
+ candidate_outcome = raw_read_range_outcome(
1047
+ fd,
1048
+ initial_stat,
1049
+ size=size,
1050
+ offset=candidate[0],
1051
+ limit=candidate[1],
1052
+ content_limit=content_limit,
1053
+ )
1054
+ if candidate_outcome == "allowed":
1055
+ outcome = "allowed"
1056
+ valve_offer = candidate
1057
+ except (OSError, ValueError) as exc:
1058
+ error_number = getattr(exc, "errno", None)
1059
+ if error_number == errno.ELOOP:
653
1060
  label = safe_label(path, root)
654
1061
  reason = (
655
1062
  f"[context-guard-kit] Read blocked for {label}: requested path traverses a symlink. "
@@ -657,34 +1064,66 @@ def main() -> int:
657
1064
  )
658
1065
  print(json.dumps(deny_response(reason), ensure_ascii=False))
659
1066
  return 0
660
- if exc.errno in {errno.EINVAL, errno.ENOTDIR, errno.ENOENT}:
1067
+ if error_number == errno.ENOENT:
661
1068
  print("{}")
662
1069
  return 0
663
1070
  label = safe_label(path, root)
664
- detail = compact_hook_text(exc.strerror or exc.__class__.__name__, 80)
1071
+ detail = compact_hook_text(getattr(exc, "strerror", "") or exc.__class__.__name__, 80)
665
1072
  print(f"context-guard-guard-read: could not safely inspect requested file: {detail}", file=sys.stderr)
666
- reason = (
667
- f"[context-guard-kit] Read blocked for {label}: the guard could not safely inspect the file "
668
- f"({detail}). Use a bounded line range or verify the path locally first."
669
- )
1073
+ if error_number in {errno.EINVAL, errno.ENOTDIR, errno.EISDIR}:
1074
+ reason = (
1075
+ f"[context-guard-kit] Read blocked for {label}: requested path is not a regular file. "
1076
+ "Use a real, non-symlink file path before reading."
1077
+ )
1078
+ else:
1079
+ reason = (
1080
+ f"[context-guard-kit] Read blocked for {label}: the guard could not safely inspect the file "
1081
+ f"({detail}). Use a bounded line range or verify the path locally first."
1082
+ )
670
1083
  print(json.dumps(deny_response(reason), ensure_ascii=False))
671
1084
  return 0
1085
+ finally:
1086
+ if fd != -1:
1087
+ os.close(fd)
672
1088
 
673
- limit = max_bytes()
674
- if size <= limit:
675
- print("{}")
1089
+ if outcome == "allowed" and valve_offer is not None:
1090
+ # commit: fd 종료 이후 밸브 발화를 지문에 1회로 기록한다(FIFO/LRU 유사 축출 유지).
1091
+ try:
1092
+ record_read_guard_attempt(root, fingerprint, valve_fired=True)
1093
+ except Exception:
1094
+ pass
1095
+ print(json.dumps(valve_updated_input_response(payload, valve_offer), ensure_ascii=False))
676
1096
  return 0
677
- if bounded_line_range_requested(payload):
1097
+
1098
+ if outcome == "allowed":
678
1099
  print("{}")
679
1100
  return 0
680
1101
 
681
- label = safe_label(path, root)
682
- read_symbol = find_read_symbol_command()
683
1102
  try:
684
- attempt_count = record_read_guard_attempt(root, read_guard_fingerprint(path, label, size))
1103
+ attempt_count = record_read_guard_attempt(root, fingerprint, valve_fired=False)
685
1104
  except Exception:
686
1105
  attempt_count = 1
687
- reason = progressive_read_ladder(path, label, size, limit, read_symbol) + repeated_read_hint(attempt_count)
1106
+
1107
+ # 3단 분기. 1) count<3: 아직 자기 교정 여지가 있으므로 전체 사다리를 준다.
1108
+ # 2) count>=3, valve_used=False: 밸브가 3회차에 시도됐고(구성상 이 카운트에
1109
+ # 도달하려면 반드시 시도됐다) 구조적으로 발화할 수 없었던 지문 — "탈진"이
1110
+ # 아니라 "애초에 켤 수 없다"이므로 실제 예산 수치가 담긴 실행 가능한 단축
1111
+ # 메시지를 쓴다. 3) valve_used=True: 과거에 실제로 발화했던 지문 — 기존
1112
+ # "탈진" 단축 메시지.
1113
+ if attempt_peek["valve_used"]:
1114
+ reason = valve_exhausted_reason(attempt_count)
1115
+ elif attempt_count >= 3:
1116
+ reason = valve_budget_exceeded_reason(attempt_count, content_limit)
1117
+ else:
1118
+ read_symbol = find_read_symbol_command()
1119
+ reason = read_proof_denial_reason(
1120
+ outcome,
1121
+ path=path,
1122
+ root=root,
1123
+ size=size,
1124
+ content_limit=content_limit,
1125
+ read_symbol=read_symbol,
1126
+ ) + repeated_read_hint(attempt_count)
688
1127
  print(json.dumps(deny_response(reason), ensure_ascii=False))
689
1128
  return 0
690
1129