@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.
- package/CHANGELOG.md +8 -0
- package/README.ko.md +46 -1
- package/README.md +58 -2
- package/package.json +1 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/README.ko.md +18 -0
- package/plugins/context-guard/README.md +18 -0
- package/plugins/context-guard/bin/context-guard-artifact +90 -9
- package/plugins/context-guard/bin/context-guard-audit +169 -66
- package/plugins/context-guard/bin/context-guard-bench +5765 -224
- package/plugins/context-guard/bin/context-guard-compress +90 -8
- package/plugins/context-guard/bin/context-guard-diet +1 -7
- package/plugins/context-guard/bin/context-guard-experiments +5 -1
- package/plugins/context-guard/bin/context-guard-failed-nudge +705 -83
- package/plugins/context-guard/bin/context-guard-guard-read +490 -55
- package/plugins/context-guard/bin/context-guard-pack +110 -11
- package/plugins/context-guard/bin/context-guard-read-symbol +7 -2
- package/plugins/context-guard/bin/context-guard-rewrite-bash +2204 -223
- package/plugins/context-guard/bin/context-guard-sanitize-output +560 -85
- package/plugins/context-guard/bin/context-guard-setup +1073 -147
- package/plugins/context-guard/bin/context-guard-statusline +131 -54
- package/plugins/context-guard/bin/context-guard-statusline-merged +7 -3
- package/plugins/context-guard/bin/context-guard-tool-prune +44 -11
- package/plugins/context-guard/bin/context-guard-trim-output +89 -13
- package/plugins/context-guard/brief/README.md +19 -0
- package/plugins/context-guard/brief/narration-mode.quiet.md +21 -0
- package/plugins/context-guard/lib/context_guard_commands.py +6 -2
- package/plugins/context-guard/lib/credential_policy.py +177 -0
- 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 =
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if
|
|
152
|
-
return
|
|
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
|
-
|
|
155
|
-
except (TypeError, ValueError):
|
|
156
|
-
return
|
|
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
|
|
159
|
-
raw_offset = data.get("offset")
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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(
|
|
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
|
-
|
|
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) + [
|
|
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
|
|
668
|
+
def project_relative_path(path: Path, root: Path) -> str | None:
|
|
505
669
|
try:
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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] =
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
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,
|
|
1099
|
+
attempt_count = record_read_guard_attempt(root, fingerprint, valve_fired=False)
|
|
685
1100
|
except Exception:
|
|
686
1101
|
attempt_count = 1
|
|
687
|
-
|
|
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
|
|