@ictechgy/context-guard 0.4.15 → 0.4.16

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 (29) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.ko.md +46 -1
  3. package/README.md +58 -2
  4. package/package.json +1 -1
  5. package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
  6. package/plugins/context-guard/README.ko.md +18 -0
  7. package/plugins/context-guard/README.md +18 -0
  8. package/plugins/context-guard/bin/context-guard-artifact +90 -9
  9. package/plugins/context-guard/bin/context-guard-audit +169 -66
  10. package/plugins/context-guard/bin/context-guard-bench +5765 -224
  11. package/plugins/context-guard/bin/context-guard-compress +90 -8
  12. package/plugins/context-guard/bin/context-guard-diet +1 -7
  13. package/plugins/context-guard/bin/context-guard-experiments +5 -1
  14. package/plugins/context-guard/bin/context-guard-failed-nudge +705 -83
  15. package/plugins/context-guard/bin/context-guard-guard-read +490 -55
  16. package/plugins/context-guard/bin/context-guard-pack +110 -11
  17. package/plugins/context-guard/bin/context-guard-read-symbol +7 -2
  18. package/plugins/context-guard/bin/context-guard-rewrite-bash +2204 -223
  19. package/plugins/context-guard/bin/context-guard-sanitize-output +560 -85
  20. package/plugins/context-guard/bin/context-guard-setup +1073 -147
  21. package/plugins/context-guard/bin/context-guard-statusline +131 -54
  22. package/plugins/context-guard/bin/context-guard-statusline-merged +7 -3
  23. package/plugins/context-guard/bin/context-guard-tool-prune +44 -11
  24. package/plugins/context-guard/bin/context-guard-trim-output +89 -13
  25. package/plugins/context-guard/brief/README.md +19 -0
  26. package/plugins/context-guard/brief/narration-mode.quiet.md +21 -0
  27. package/plugins/context-guard/lib/context_guard_commands.py +6 -2
  28. package/plugins/context-guard/lib/credential_policy.py +177 -0
  29. 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,6 +409,114 @@ 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():
@@ -473,10 +628,19 @@ def line_estimate(prefix: str, size: int, truncated: bool) -> str:
473
628
  return f"~{estimated} (estimated from first {lines})"
474
629
 
475
630
 
476
- def progressive_read_ladder(path: Path, label: str, size: int, limit: int, read_symbol: str) -> str:
631
+ def progressive_read_ladder(
632
+ path: Path,
633
+ label: str,
634
+ size: int,
635
+ limit: int,
636
+ read_symbol: str,
637
+ *,
638
+ command_path: str | None = None,
639
+ ) -> str:
477
640
  prefix, prefix_truncated = read_prefix_for_outline(path)
478
641
  items = outline_items(path, prefix)
479
- rg_cmd, symbol_cmd = suggested_commands(label, read_symbol)
642
+ actionable_path = command_path if command_path is not None else label
643
+ rg_cmd, symbol_cmd = suggested_commands(actionable_path, read_symbol)
480
644
  range_limit = min(max_line_range(), 120)
481
645
  parts = [
482
646
  f"[context-guard-kit] Large Read blocked for {label} ({size} bytes > {limit} byte guard).",
@@ -485,7 +649,7 @@ def progressive_read_ladder(path: Path, label: str, size: int, limit: int, read_
485
649
  ]
486
650
  if items:
487
651
  first_name = items[0].split(" ", 3)[-1].split(" ", 1)[-1]
488
- read_parts = shlex.split(read_symbol) + [label, first_name]
652
+ read_parts = shlex.split(read_symbol) + [actionable_path, first_name]
489
653
  parts.append(f"2) Read a symbol slice: `{shlex.join(read_parts)}` (or `{symbol_cmd}`)")
490
654
  else:
491
655
  parts.append(f"2) Read a symbol slice when you know the name: `{symbol_cmd}`")
@@ -501,12 +665,111 @@ def progressive_read_ladder(path: Path, label: str, size: int, limit: int, read_
501
665
  return " ".join(parts)
502
666
 
503
667
 
504
- def read_guard_fingerprint(path: Path, label: str, size: int) -> str:
668
+ def project_relative_path(path: Path, root: Path) -> str | None:
505
669
  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:
670
+ normalized_path = Path(os.path.abspath(os.fspath(path)))
671
+ normalized_root = Path(os.path.abspath(os.fspath(root)))
672
+ return normalized_path.relative_to(normalized_root).as_posix()
673
+ except (OSError, ValueError):
674
+ return None
675
+
676
+
677
+ def project_relative_command_path(path: Path, root: Path) -> str | None:
678
+ relative = project_relative_path(path, root)
679
+ if relative is None:
680
+ return None
681
+ if (
682
+ not relative
683
+ or len(relative) > PATH_LABEL_MAX_CHARS
684
+ or CONTROL_CHAR_RE.search(relative)
685
+ or hook_label_has_sensitive_evidence(relative)
686
+ ):
687
+ return None
688
+ return relative
689
+
690
+
691
+ def read_proof_denial_reason(
692
+ outcome: str,
693
+ *,
694
+ path: Path,
695
+ root: Path,
696
+ size: int,
697
+ content_limit: int,
698
+ read_symbol: str,
699
+ ) -> str:
700
+ relative_project_path = project_relative_path(path, root)
701
+ relative_path = project_relative_command_path(path, root)
702
+ if outcome == "file_changed_during_proof":
703
+ if relative_project_path is None:
704
+ target = "an out-of-project file"
705
+ elif relative_path is None:
706
+ target = safe_label(path, root)
707
+ else:
708
+ target = f"project file `{shlex.quote(relative_path)}`"
709
+ return (
710
+ f"[context-guard-kit] Read blocked for {target}: file_changed_during_proof. "
711
+ "The same open file changed identity, size, or modification time during the bounded proof. "
712
+ "Stabilize the file and retry with a smaller positive limit. A later Read uses a separate open, "
713
+ "so replacement after this hook returns remains a TOCTOU limitation."
714
+ )
715
+
716
+ outcome_detail = {
717
+ "invalid_read_range": (
718
+ "Large files require a positive integer limit within the configured maximum and a "
719
+ "zero-based, nonnegative, nonoverflowing integer offset."
720
+ ),
721
+ "proof_budget_exhausted": (
722
+ "The guard could not prove the requested start/end boundary or EOF within the raw-byte proof budget."
723
+ ),
724
+ "content_budget_exceeded": (
725
+ "The selected logical-line content exceeds the byte guard; LF terminators are not charged, "
726
+ "but CR and EOF-final content bytes are."
727
+ ),
728
+ }.get(outcome, "The bounded raw-byte Read proof could not safely allow this request.")
729
+
730
+ if relative_project_path is None:
731
+ return (
732
+ f"[context-guard-kit] Large Read blocked for an out-of-project file "
733
+ f"({size} bytes > {content_limit} byte guard): {outcome}. {outcome_detail} "
734
+ "Use a smaller positive limit and lower zero-based offset, or first perform an explicitly "
735
+ "user-authorized path-visible operation. No executable path suggestion is emitted for path privacy."
736
+ )
737
+ if relative_path is None:
738
+ return (
739
+ f"[context-guard-kit] Large Read blocked for {safe_label(path, root)} "
740
+ f"({size} bytes > {content_limit} byte guard): {outcome}. {outcome_detail} "
741
+ "Use a smaller positive limit and lower zero-based offset. No executable path suggestion is emitted "
742
+ "because this project-relative path contains privacy-sensitive or non-command-safe bytes."
743
+ )
744
+
745
+ label = safe_label(path, root)
746
+ ladder = progressive_read_ladder(
747
+ path,
748
+ label,
749
+ size,
750
+ content_limit,
751
+ read_symbol,
752
+ command_path=relative_path,
753
+ )
754
+ return f"{ladder} Read proof outcome={outcome}. {outcome_detail}"
755
+
756
+
757
+ def read_guard_fingerprint(
758
+ path: Path,
759
+ label: str,
760
+ size: int,
761
+ *,
762
+ stat_result: os.stat_result | None = None,
763
+ ) -> str:
764
+ if stat_result is None:
765
+ try:
766
+ stat_result = path.stat()
767
+ except OSError:
768
+ stat_result = None
769
+ if stat_result is None:
509
770
  mtime = 0
771
+ else:
772
+ mtime = getattr(stat_result, "st_mtime_ns", int(stat_result.st_mtime * 1_000_000_000))
510
773
  basis = f"{label}\0{size}\0{mtime}"
511
774
  return hashlib.sha256(basis.encode("utf-8", errors="replace")).hexdigest()[:16]
512
775
 
@@ -568,26 +831,66 @@ def save_read_guard_state(root: Path, state: dict[str, Any]) -> None:
568
831
  return
569
832
 
570
833
 
571
- def record_read_guard_attempt(root: Path, fp: str) -> int:
834
+ def default_read_guard_entry() -> dict[str, Any]:
835
+ """캐시에 아직 없거나 손상된 지문에 사용할 기본 시도 엔트리."""
836
+ return {"count": 0, "valve_used": False, "first_seen": 0, "last_seen": 0}
837
+
838
+
839
+ def normalize_read_guard_entry(entry: Any) -> dict[str, Any]:
840
+ """레거시 `{"count": N}` 및 손상된 엔트리를 신규 스키마로 하위 호환 정규화한다."""
841
+ normalized = default_read_guard_entry()
842
+ if not isinstance(entry, dict):
843
+ return normalized
844
+ normalized["count"] = bounded_int(entry.get("count", 0), 0, 0, 1_000_000)
845
+ normalized["valve_used"] = bool(entry.get("valve_used", False))
846
+ normalized["first_seen"] = bounded_int(entry.get("first_seen", 0), 0, 0, MAX_READ_RANGE_INTEGER)
847
+ normalized["last_seen"] = bounded_int(entry.get("last_seen", 0), 0, 0, MAX_READ_RANGE_INTEGER)
848
+ return normalized
849
+
850
+
851
+ def peek_read_guard_attempt(root: Path, fp: str) -> dict[str, Any]:
852
+ """카운터를 증가시키지 않고 현재 지문의 시도 엔트리를 읽는다(밸브 판정 전용).
853
+
854
+ 밸브 발화 여부는 대상 fd 보유 구간 안에서 결정해야 하므로, 영속화(commit)와
855
+ 분리된 읽기 전용 조회로 둔다. 대상 파일의 fd는 건드리지 않는다.
856
+ """
857
+ state = load_read_guard_state(root)
858
+ attempts = state.get("attempts")
859
+ if not isinstance(attempts, dict):
860
+ return default_read_guard_entry()
861
+ return normalize_read_guard_entry(attempts.get(fp))
862
+
863
+
864
+ def record_read_guard_attempt(root: Path, fp: str, *, valve_fired: bool = False) -> int:
865
+ """시도 횟수를 1 증가시키고 병합 방식으로 영속화한다(commit 단계, fd 미보유 구간).
866
+
867
+ 기존 엔트리를 통째로 덮어쓰지 않고 필드를 병합해 valve_used/first_seen/last_seen을
868
+ 보존한다. pop 후 재삽입 순서를 유지해 초과분 축출을 LRU 유사하게 만든다.
869
+ valve_used 등 전체 엔트리 조회가 필요하면 별도로 peek_read_guard_attempt를 쓴다
870
+ (이 함수의 반환값은 호출부가 실제로 쓰는 count만 담는다).
871
+ """
572
872
  state = load_read_guard_state(root)
573
873
  attempts = state.get("attempts")
574
874
  if not isinstance(attempts, dict):
575
875
  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
876
+ entry = normalize_read_guard_entry(attempts.get(fp))
877
+ now = int(time.time())
878
+ entry["count"] += 1
879
+ entry["valve_used"] = entry["valve_used"] or valve_fired
880
+ entry["first_seen"] = entry["first_seen"] or now
881
+ entry["last_seen"] = now
580
882
  attempts.pop(fp, None)
581
- attempts[fp] = {"count": count}
883
+ attempts[fp] = entry
582
884
  if len(attempts) > READ_GUARD_STATE_MAX_ITEMS:
583
885
  for key in list(attempts)[: len(attempts) - READ_GUARD_STATE_MAX_ITEMS]:
584
886
  attempts.pop(key, None)
585
887
  state["attempts"] = attempts
586
888
  save_read_guard_state(root, state)
587
- return count
889
+ return entry["count"]
588
890
 
589
891
 
590
892
  def repeated_read_hint(count: int) -> str:
893
+ """1~2회차 사다리 거부에 덧붙이는 반복 신호 문구(3회차부터는 단축 메시지를 대신 쓴다)."""
591
894
  if count < 2:
592
895
  return ""
593
896
  return (
@@ -596,6 +899,30 @@ def repeated_read_hint(count: int) -> str:
596
899
  )
597
900
 
598
901
 
902
+ def valve_exhausted_reason(count: int) -> str:
903
+ """지문이 과거에 실제로 밸브를 1회 발화(valve_used=True)한 뒤 다시 차단됐을 때 쓰는
904
+ 200바이트 미만 단축 메시지. "발화했다가 소진됨"을 뜻하므로 발화한 적이 없는
905
+ 지문에는 절대 쓰지 않는다 — 그 경우는 valve_budget_exceeded_reason을 쓴다.
906
+ """
907
+ return (
908
+ f"[context-guard-kit] Read blocked ({count}x, escape valve exhausted). "
909
+ "Use a smaller offset/limit range for this file."
910
+ )
911
+
912
+
913
+ def valve_budget_exceeded_reason(count: int, content_limit: int) -> str:
914
+ """좁힌 기본 범위(offset=0, limit=max_line_range())조차 예산을 넘어 밸브가 구조적으로
915
+ 발화할 수 없는 지문(minified/JSON/CSV/로그처럼 줄이 긴 파일)에 쓰는 200바이트 미만
916
+ 단축 메시지. "탈진"이라 말하지 않는다 — 애초에 켜진 적이 없기 때문이다. 실제 예산
917
+ 수치를 실어 에이전트가 스스로 유효한 offset/limit을 계산할 수 있게 한다.
918
+ """
919
+ return (
920
+ f"[context-guard-kit] Large Read blocked ({count}x). Narrowed {max_line_range()}-line "
921
+ f"range still exceeds the {content_limit:,}-byte guard; supply an explicit offset/limit "
922
+ "under it."
923
+ )
924
+
925
+
599
926
  def deny_response(reason: str) -> dict[str, Any]:
600
927
  return {
601
928
  "hookSpecificOutput": {
@@ -606,6 +933,18 @@ def deny_response(reason: str) -> dict[str, Any]:
606
933
  }
607
934
 
608
935
 
936
+ def valve_updated_input_response(payload: dict[str, Any], offer: tuple[int, int]) -> dict[str, Any]:
937
+ """3회차 밸브가 발화했을 때 offset/limit을 주입한 updatedInput 훅 응답을 만든다."""
938
+ updated_input = copy.deepcopy(tool_input(payload))
939
+ updated_input["offset"], updated_input["limit"] = offer
940
+ return {
941
+ "hookSpecificOutput": {
942
+ "hookEventName": "PreToolUse",
943
+ "updatedInput": updated_input,
944
+ }
945
+ }
946
+
947
+
609
948
  def main() -> int:
610
949
  if any(arg in {"-h", "--help"} for arg in sys.argv[1:]):
611
950
  print("ContextGuard helper: context-guard-guard-read")
@@ -634,11 +973,21 @@ def main() -> int:
634
973
  print("{}")
635
974
  return 0
636
975
  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):
976
+ try:
977
+ path = Path(raw_path).expanduser()
978
+ if not path.is_absolute():
979
+ path = root / path
980
+ path = Path(os.path.abspath(os.fspath(path)))
981
+ path = normalize_allowed_first_absolute_symlink(path)
982
+ traverses_symlink = has_symlink_component(path)
983
+ except (OSError, RuntimeError, ValueError):
984
+ reason = (
985
+ "[context-guard-kit] Read blocked because the requested file path could not be normalized safely. "
986
+ "Retry with a valid, explicit file path."
987
+ )
988
+ print(json.dumps(deny_response(reason), ensure_ascii=False))
989
+ return 0
990
+ if traverses_symlink:
642
991
  label = safe_label(path, root)
643
992
  reason = (
644
993
  f"[context-guard-kit] Read blocked for {label}: requested path traverses a symlink. "
@@ -646,10 +995,64 @@ def main() -> int:
646
995
  )
647
996
  print(json.dumps(deny_response(reason), ensure_ascii=False))
648
997
  return 0
998
+ if read_env_file_denied(path):
999
+ reason = (
1000
+ "[context-guard-kit] Read blocked by the Read-only environment-file policy: the normalized basename "
1001
+ "begins with .env and is not exactly .env.example, .env.sample, or .env.template. "
1002
+ "This hook protects Claude Read only; Glob name listings, Grep, and Bash/process access are out of scope."
1003
+ )
1004
+ print(json.dumps(deny_response(reason), ensure_ascii=False))
1005
+ return 0
1006
+ content_limit = max_bytes()
1007
+ size = 0
1008
+ initial_stat: os.stat_result | None = None
1009
+ outcome = "invalid_read_range"
1010
+ fingerprint = ""
1011
+ valve_offer: tuple[int, int] | None = None
1012
+ fd = -1
649
1013
  try:
650
- size = regular_file_size_no_symlink(path)
651
- except OSError as exc:
652
- if exc.errno == errno.ELOOP:
1014
+ fd = open_regular_no_symlink(path)
1015
+ initial_stat = os.fstat(fd)
1016
+ size = initial_stat.st_size
1017
+ if size <= content_limit:
1018
+ print("{}")
1019
+ return 0
1020
+
1021
+ # peek: fd 보유 구간 안에서 밸브 판정에 쓸 이전 시도 횟수를 읽는다(쓰기 없음).
1022
+ fingerprint = read_guard_fingerprint(path, safe_label(path, root), size, stat_result=initial_stat)
1023
+ attempt_peek = peek_read_guard_attempt(root, fingerprint)
1024
+
1025
+ requested_range = large_read_range(payload)
1026
+ if requested_range is not None:
1027
+ outcome = raw_read_range_outcome(
1028
+ fd,
1029
+ initial_stat,
1030
+ size=size,
1031
+ offset=requested_range[0],
1032
+ limit=requested_range[1],
1033
+ content_limit=content_limit,
1034
+ )
1035
+
1036
+ if (
1037
+ outcome not in ("allowed", "file_changed_during_proof")
1038
+ and attempt_peek["count"] + 1 == 3
1039
+ and not attempt_peek["valve_used"]
1040
+ ):
1041
+ candidate = (0, max_line_range())
1042
+ candidate_outcome = raw_read_range_outcome(
1043
+ fd,
1044
+ initial_stat,
1045
+ size=size,
1046
+ offset=candidate[0],
1047
+ limit=candidate[1],
1048
+ content_limit=content_limit,
1049
+ )
1050
+ if candidate_outcome == "allowed":
1051
+ outcome = "allowed"
1052
+ valve_offer = candidate
1053
+ except (OSError, ValueError) as exc:
1054
+ error_number = getattr(exc, "errno", None)
1055
+ if error_number == errno.ELOOP:
653
1056
  label = safe_label(path, root)
654
1057
  reason = (
655
1058
  f"[context-guard-kit] Read blocked for {label}: requested path traverses a symlink. "
@@ -657,34 +1060,66 @@ def main() -> int:
657
1060
  )
658
1061
  print(json.dumps(deny_response(reason), ensure_ascii=False))
659
1062
  return 0
660
- if exc.errno in {errno.EINVAL, errno.ENOTDIR, errno.ENOENT}:
1063
+ if error_number == errno.ENOENT:
661
1064
  print("{}")
662
1065
  return 0
663
1066
  label = safe_label(path, root)
664
- detail = compact_hook_text(exc.strerror or exc.__class__.__name__, 80)
1067
+ detail = compact_hook_text(getattr(exc, "strerror", "") or exc.__class__.__name__, 80)
665
1068
  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
- )
1069
+ if error_number in {errno.EINVAL, errno.ENOTDIR, errno.EISDIR}:
1070
+ reason = (
1071
+ f"[context-guard-kit] Read blocked for {label}: requested path is not a regular file. "
1072
+ "Use a real, non-symlink file path before reading."
1073
+ )
1074
+ else:
1075
+ reason = (
1076
+ f"[context-guard-kit] Read blocked for {label}: the guard could not safely inspect the file "
1077
+ f"({detail}). Use a bounded line range or verify the path locally first."
1078
+ )
670
1079
  print(json.dumps(deny_response(reason), ensure_ascii=False))
671
1080
  return 0
1081
+ finally:
1082
+ if fd != -1:
1083
+ os.close(fd)
672
1084
 
673
- limit = max_bytes()
674
- if size <= limit:
675
- print("{}")
1085
+ if outcome == "allowed" and valve_offer is not None:
1086
+ # commit: fd 종료 이후 밸브 발화를 지문에 1회로 기록한다(FIFO/LRU 유사 축출 유지).
1087
+ try:
1088
+ record_read_guard_attempt(root, fingerprint, valve_fired=True)
1089
+ except Exception:
1090
+ pass
1091
+ print(json.dumps(valve_updated_input_response(payload, valve_offer), ensure_ascii=False))
676
1092
  return 0
677
- if bounded_line_range_requested(payload):
1093
+
1094
+ if outcome == "allowed":
678
1095
  print("{}")
679
1096
  return 0
680
1097
 
681
- label = safe_label(path, root)
682
- read_symbol = find_read_symbol_command()
683
1098
  try:
684
- attempt_count = record_read_guard_attempt(root, read_guard_fingerprint(path, label, size))
1099
+ attempt_count = record_read_guard_attempt(root, fingerprint, valve_fired=False)
685
1100
  except Exception:
686
1101
  attempt_count = 1
687
- reason = progressive_read_ladder(path, label, size, limit, read_symbol) + repeated_read_hint(attempt_count)
1102
+
1103
+ # 3단 분기. 1) count<3: 아직 자기 교정 여지가 있으므로 전체 사다리를 준다.
1104
+ # 2) count>=3, valve_used=False: 밸브가 3회차에 시도됐고(구성상 이 카운트에
1105
+ # 도달하려면 반드시 시도됐다) 구조적으로 발화할 수 없었던 지문 — "탈진"이
1106
+ # 아니라 "애초에 켤 수 없다"이므로 실제 예산 수치가 담긴 실행 가능한 단축
1107
+ # 메시지를 쓴다. 3) valve_used=True: 과거에 실제로 발화했던 지문 — 기존
1108
+ # "탈진" 단축 메시지.
1109
+ if attempt_peek["valve_used"]:
1110
+ reason = valve_exhausted_reason(attempt_count)
1111
+ elif attempt_count >= 3:
1112
+ reason = valve_budget_exceeded_reason(attempt_count, content_limit)
1113
+ else:
1114
+ read_symbol = find_read_symbol_command()
1115
+ reason = read_proof_denial_reason(
1116
+ outcome,
1117
+ path=path,
1118
+ root=root,
1119
+ size=size,
1120
+ content_limit=content_limit,
1121
+ read_symbol=read_symbol,
1122
+ ) + repeated_read_hint(attempt_count)
688
1123
  print(json.dumps(deny_response(reason), ensure_ascii=False))
689
1124
  return 0
690
1125