@ictechgy/context-guard 0.4.9 → 0.4.10
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 +16 -0
- package/README.ko.md +41 -24
- package/README.md +66 -26
- package/docs/benchmark-fixtures/token-savings-12task-baseline.prompt.example.md +7 -0
- package/docs/benchmark-fixtures/token-savings-12task-contextguard.prompt.example.md +7 -0
- package/docs/benchmark-fixtures/token-savings-12task.tasks.example.json +182 -0
- package/docs/benchmark-fixtures/token-savings-12task.variants.example.json +10 -0
- package/docs/distribution.md +10 -7
- package/docs/experimental-benchmark-fixtures.md +8 -1
- package/package.json +3 -6
- 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 +9 -6
- package/plugins/context-guard/README.md +21 -13
- package/plugins/context-guard/bin/context-guard +113 -26
- package/plugins/context-guard/bin/context-guard-artifact +542 -46
- package/plugins/context-guard/bin/context-guard-cache-score +380 -0
- package/plugins/context-guard/bin/context-guard-compress +146 -1
- package/plugins/context-guard/bin/context-guard-cost +783 -4
- package/plugins/context-guard/bin/context-guard-experiments +99 -18
- package/plugins/context-guard/bin/context-guard-failed-nudge +3 -0
- package/plugins/context-guard/bin/context-guard-filter +163 -7
- package/plugins/context-guard/bin/context-guard-guard-read +3 -0
- package/plugins/context-guard/bin/context-guard-pack +602 -43
- package/plugins/context-guard/bin/context-guard-rewrite-bash +3 -0
- package/plugins/context-guard/bin/context-guard-setup +165 -31
- package/plugins/context-guard/bin/context-guard-statusline +490 -283
- package/plugins/context-guard/bin/context-guard-statusline-merged +5 -0
- package/plugins/context-guard/bin/context-guard-tool-prune +241 -1
- package/plugins/context-guard/lib/context_guard_commands.py +206 -0
- package/plugins/context-guard/skills/setup/SKILL.md +1 -0
- package/context-guard-kit/README.md +0 -91
- package/context-guard-kit/benchmark_runner.py +0 -2401
- package/context-guard-kit/claude_transcript_cost_audit.py +0 -2346
- package/context-guard-kit/context_compress.py +0 -695
- package/context-guard-kit/context_escrow.py +0 -935
- package/context-guard-kit/context_filter.py +0 -637
- package/context-guard-kit/context_guard_cli.py +0 -325
- package/context-guard-kit/context_guard_diet.py +0 -1711
- package/context-guard-kit/context_pack.py +0 -2713
- package/context-guard-kit/cost_guard.py +0 -2349
- package/context-guard-kit/experimental_registry.py +0 -4348
- package/context-guard-kit/failed_attempt_nudge.py +0 -567
- package/context-guard-kit/guard_large_read.py +0 -690
- package/context-guard-kit/hook_secret_patterns.py +0 -43
- package/context-guard-kit/read_symbol.py +0 -483
- package/context-guard-kit/rewrite_bash_for_token_budget.py +0 -501
- package/context-guard-kit/sanitize_output.py +0 -725
- package/context-guard-kit/settings.example.json +0 -67
- package/context-guard-kit/setup_wizard.py +0 -2515
- package/context-guard-kit/statusline.sh +0 -362
- package/context-guard-kit/statusline_merged.sh +0 -157
- package/context-guard-kit/tool_schema_pruner.py +0 -837
- package/context-guard-kit/trim_command_output.py +0 -1449
|
@@ -1,567 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Claude Code PostToolUse hook: 동일 Bash 명령이 연속 실패하면 /clear 권유.
|
|
3
|
-
|
|
4
|
-
같은 명령으로 두 번 연속 실패하면 그 흐름은 컨텍스트 오염을 일으키고 prompt cache 도
|
|
5
|
-
매 retry 마다 재워밍된다. 이 hook 은 그 패턴을 감지해 다음 turn 의 추가 컨텍스트로
|
|
6
|
-
짧은 모델 힌트를 주입한다 (블록하지 않음).
|
|
7
|
-
|
|
8
|
-
PostToolUse 의 `hookSpecificOutput.additionalContext` 는 Claude Code 공식 hook 명세상
|
|
9
|
-
모델 컨텍스트로 surfacing 되는 키이다 (https://code.claude.com/docs/en/hooks 참조).
|
|
10
|
-
|
|
11
|
-
상태 저장: 프로젝트 로컬 `.context-guard/failures-<session>.json`.
|
|
12
|
-
session_id 가 없으면 cross-session 오염을 피하기 위해 hook 자체를 noop 한다.
|
|
13
|
-
같은 fingerprint 가 한 번이라도 성공하면 카운트를 리셋한다 (false-positive 방지).
|
|
14
|
-
트래킹 깊이는 5 회로 제한해 디스크 사용을 무시할 수 있게 한다.
|
|
15
|
-
|
|
16
|
-
Install via `.claude/settings.json` PostToolUse hook with matcher "Bash".
|
|
17
|
-
"""
|
|
18
|
-
from __future__ import annotations
|
|
19
|
-
|
|
20
|
-
import errno
|
|
21
|
-
import hashlib
|
|
22
|
-
import importlib.util
|
|
23
|
-
import json
|
|
24
|
-
import os
|
|
25
|
-
import re
|
|
26
|
-
import shlex
|
|
27
|
-
import stat
|
|
28
|
-
import sys
|
|
29
|
-
import uuid
|
|
30
|
-
from pathlib import Path
|
|
31
|
-
|
|
32
|
-
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def _load_hook_secret_patterns():
|
|
36
|
-
searched = []
|
|
37
|
-
for helper_dir in (SCRIPT_DIR, SCRIPT_DIR.parent / "lib"):
|
|
38
|
-
helper_path = helper_dir / "hook_secret_patterns.py"
|
|
39
|
-
searched.append(str(helper_path))
|
|
40
|
-
if not helper_path.is_file():
|
|
41
|
-
continue
|
|
42
|
-
spec = importlib.util.spec_from_file_location("_claude_token_hook_secret_patterns", helper_path)
|
|
43
|
-
if spec is None or spec.loader is None:
|
|
44
|
-
continue
|
|
45
|
-
module = importlib.util.module_from_spec(spec)
|
|
46
|
-
spec.loader.exec_module(module)
|
|
47
|
-
return module
|
|
48
|
-
raise ImportError("hook_secret_patterns.py not found in " + ", ".join(searched))
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
_hook_secret_patterns = _load_hook_secret_patterns()
|
|
52
|
-
redact_sensitive_hook_text = _hook_secret_patterns.redact_sensitive_hook_text
|
|
53
|
-
|
|
54
|
-
STATE_DIR = Path(".context-guard")
|
|
55
|
-
STATE_FILE_TEMPLATE = "failures-{session}.json"
|
|
56
|
-
MAX_TRACKED = 5
|
|
57
|
-
MIN_CONSECUTIVE = 2
|
|
58
|
-
STRATEGY_SWITCH_MIN_CONSECUTIVE = 3
|
|
59
|
-
FINGERPRINT_SELECTOR_FLAGS = {"-k", "-m", "--grep", "--testNamePattern", "--test-name-pattern"}
|
|
60
|
-
DIAGNOSTIC_MAX_CHARS = 240
|
|
61
|
-
MAX_HOOK_STDIN_BYTES = 1_000_000
|
|
62
|
-
ANSI_ESCAPE_RE = re.compile(r"(?:\x1b\[[0-?]*[ -/]*[@-~]|\x9b[0-?]*[ -/]*[@-~])")
|
|
63
|
-
CONTROL_CHAR_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]")
|
|
64
|
-
UNSUPPORTED_STATE_IO_ERRNO = getattr(errno, "ENOTSUP", getattr(errno, "EOPNOTSUPP", errno.EINVAL))
|
|
65
|
-
UNSAFE_STATE_PATH_ERRNOS = {
|
|
66
|
-
errno.ELOOP,
|
|
67
|
-
errno.ENOTDIR,
|
|
68
|
-
errno.EISDIR,
|
|
69
|
-
}
|
|
70
|
-
ALLOWED_FIRST_ABSOLUTE_SYMLINKS = {
|
|
71
|
-
# macOS exposes these as first-component symlinks to /private/*. Allow only
|
|
72
|
-
# this OS-owned alias so tests and hooks in TMPDIR can still use no-follow
|
|
73
|
-
# traversal without accepting arbitrary user-controlled symlink parents.
|
|
74
|
-
"tmp": Path("/private/tmp"),
|
|
75
|
-
"var": Path("/private/var"),
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
class UnsupportedSafeStateIOError(OSError):
|
|
80
|
-
"""현재 플랫폼에서 no-follow state IO 를 안전하게 보장할 수 없음."""
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
class UnsafeStatePathError(OSError):
|
|
84
|
-
"""state path 가 symlink/비정규 파일/부적절한 경로 형태라 거부됨."""
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
# additionalContext 는 모델에게 주입되므로 사용자에게 직접 명령하는 톤보다 모델이 행동을
|
|
88
|
-
# 결정할 때 참고할 힌트 형태가 자연스럽다. 모델이 사용자에게 안내하도록 유도한다.
|
|
89
|
-
NUDGE_TEXT = (
|
|
90
|
-
"AI 힌트: 동일 Bash 명령이 이 세션에서 연속 두 번 실패했습니다. "
|
|
91
|
-
"이는 현재 접근 방식이 같은 방향으로 막혀 있고, 실패 시도가 누적될수록 컨텍스트가 오염되며 "
|
|
92
|
-
"prompt cache 도 매 retry 마다 재워밍됨을 의미합니다. "
|
|
93
|
-
"재시도 전에 사용자에게 `/clear` 또는 `/compact focus on …` 으로 세션을 정리한 뒤 "
|
|
94
|
-
"재현 명령·기대 결과·금지 사항을 더 좁혀 다시 prompt 하도록 안내하거나, "
|
|
95
|
-
"근본적으로 다른 방향(다른 모듈 / 검증 명령 / 더 작은 재현)을 제안하세요."
|
|
96
|
-
)
|
|
97
|
-
STRATEGY_SWITCH_TEXT = (
|
|
98
|
-
" Strategy-switch signal: the same failure direction has now repeated at least three times. "
|
|
99
|
-
"Stop retrying the identical command path; summarize the invariant failure, choose a different hypothesis "
|
|
100
|
-
"or smaller reproducer, and only rerun after changing code, inputs, or diagnostic scope."
|
|
101
|
-
)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def normalize_command(command: str) -> str:
|
|
105
|
-
"""명령을 stable fingerprint 텍스트로 축약한다.
|
|
106
|
-
|
|
107
|
-
"방향" 만 보존하기 위해 모든 `-`/`--` 옵션을 제거하고 positional 토큰 중 처음
|
|
108
|
-
2 개(보통 `command primary_target`)와 대표 selector 옵션을 남긴다. 예:
|
|
109
|
-
- `pytest tests/auth.py`, `pytest tests/auth.py -v` 는 같은 fingerprint.
|
|
110
|
-
- `pytest tests/auth.py -k login` 과 `pytest tests/auth.py -k logout` 은 다른 fingerprint.
|
|
111
|
-
- `pytest tests/billing.py` 는 다른 fingerprint.
|
|
112
|
-
|
|
113
|
-
한계:
|
|
114
|
-
- flag value 가 positional 으로 잘못 잡혀도 첫 2 개만 보므로 영향이 거의 없다.
|
|
115
|
-
- 같은 작업을 여러 대상에 나눠 실행하면 (`pytest A` 후 `pytest B`) 다른 fp 로 본다.
|
|
116
|
-
이 단순화는 도구별 옵션 목록 유지비용 없이 운영 의도("같은 방향으로 두 번 실패하면
|
|
117
|
-
권유") 와 가장 잘 맞도록 의도적으로 거칠게 잡았다.
|
|
118
|
-
"""
|
|
119
|
-
try:
|
|
120
|
-
argv = shlex.split(command)
|
|
121
|
-
except ValueError:
|
|
122
|
-
argv = command.split()
|
|
123
|
-
positional: list[str] = []
|
|
124
|
-
selectors: list[tuple[str, str]] = []
|
|
125
|
-
index = 0
|
|
126
|
-
while index < len(argv):
|
|
127
|
-
token = argv[index]
|
|
128
|
-
flag, sep, inline_value = token.partition("=")
|
|
129
|
-
if flag in FINGERPRINT_SELECTOR_FLAGS:
|
|
130
|
-
value = inline_value if sep else (argv[index + 1] if index + 1 < len(argv) else "")
|
|
131
|
-
if value:
|
|
132
|
-
selectors.append((flag, value))
|
|
133
|
-
if not sep:
|
|
134
|
-
index += 1
|
|
135
|
-
elif token != "--" and not token.startswith("-"):
|
|
136
|
-
positional.append(token)
|
|
137
|
-
index += 1
|
|
138
|
-
normalized = positional[:2]
|
|
139
|
-
selector_text = [f"{flag}={value}" for flag, value in sorted(selectors, key=lambda item: item[0])]
|
|
140
|
-
return " ".join([*normalized, *selector_text])
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
def fingerprint(normalized: str) -> str:
|
|
144
|
-
return hashlib.sha256(normalized.encode("utf-8", errors="replace")).hexdigest()[:16]
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
def _base_open_flags() -> int:
|
|
148
|
-
flags = os.O_RDONLY
|
|
149
|
-
if hasattr(os, "O_CLOEXEC"):
|
|
150
|
-
flags |= os.O_CLOEXEC
|
|
151
|
-
if hasattr(os, "O_NONBLOCK"):
|
|
152
|
-
flags |= os.O_NONBLOCK
|
|
153
|
-
return flags
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
def _no_follow_flag() -> int:
|
|
157
|
-
if hasattr(os, "O_NOFOLLOW"):
|
|
158
|
-
return os.O_NOFOLLOW
|
|
159
|
-
raise UnsupportedSafeStateIOError(
|
|
160
|
-
UNSUPPORTED_STATE_IO_ERRNO,
|
|
161
|
-
"failed-attempt nudge state requires POSIX no-follow file opens",
|
|
162
|
-
)
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
def _directory_flag() -> int:
|
|
166
|
-
return getattr(os, "O_DIRECTORY", 0)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
def _normalized_link_target(parent: Path, raw_target: str) -> Path:
|
|
170
|
-
target = Path(raw_target)
|
|
171
|
-
if not target.is_absolute():
|
|
172
|
-
target = parent / target
|
|
173
|
-
return Path(os.path.normpath(str(target)))
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
def _normalize_allowed_first_absolute_symlink(path: Path) -> Path:
|
|
177
|
-
if not path.is_absolute() or len(path.parts) < 2:
|
|
178
|
-
return path
|
|
179
|
-
first = path.parts[1]
|
|
180
|
-
expected = ALLOWED_FIRST_ABSOLUTE_SYMLINKS.get(first)
|
|
181
|
-
if expected is None:
|
|
182
|
-
return path
|
|
183
|
-
link = Path(path.anchor) / first
|
|
184
|
-
try:
|
|
185
|
-
if not stat.S_ISLNK(os.lstat(link).st_mode):
|
|
186
|
-
return path
|
|
187
|
-
if _normalized_link_target(Path(path.anchor), os.readlink(link)) != expected:
|
|
188
|
-
return path
|
|
189
|
-
except OSError:
|
|
190
|
-
return path
|
|
191
|
-
return expected.joinpath(*path.parts[2:])
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def _open_directory_at(dir_fd: int, component: str, path: Path) -> int:
|
|
195
|
-
fd = os.open(component, _base_open_flags() | _directory_flag() | _no_follow_flag(), dir_fd=dir_fd)
|
|
196
|
-
try:
|
|
197
|
-
if not stat.S_ISDIR(os.fstat(fd).st_mode):
|
|
198
|
-
raise UnsafeStatePathError(errno.ENOTDIR, "not a directory", str(path))
|
|
199
|
-
return fd
|
|
200
|
-
except Exception:
|
|
201
|
-
os.close(fd)
|
|
202
|
-
raise
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
def _mkdir_directory_at(dir_fd: int, component: str) -> None:
|
|
206
|
-
os.mkdir(component, 0o777, dir_fd=dir_fd)
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
def _ensure_directory_no_symlink(path: Path, *, create: bool = False) -> int:
|
|
210
|
-
if os.open not in os.supports_dir_fd or os.mkdir not in os.supports_dir_fd:
|
|
211
|
-
raise UnsupportedSafeStateIOError(
|
|
212
|
-
UNSUPPORTED_STATE_IO_ERRNO,
|
|
213
|
-
"failed-attempt nudge state requires directory-relative no-follow access",
|
|
214
|
-
)
|
|
215
|
-
path = _normalize_allowed_first_absolute_symlink(path)
|
|
216
|
-
components = list(path.parts)
|
|
217
|
-
if path.is_absolute() and components:
|
|
218
|
-
components = components[1:]
|
|
219
|
-
root = path.anchor if path.is_absolute() else "."
|
|
220
|
-
dir_fd = os.open(root or ".", _base_open_flags() | _directory_flag())
|
|
221
|
-
try:
|
|
222
|
-
for component in components:
|
|
223
|
-
if component in {"", "."}:
|
|
224
|
-
continue
|
|
225
|
-
if component == "..":
|
|
226
|
-
raise UnsafeStatePathError(errno.EINVAL, "parent traversal is not allowed", str(path))
|
|
227
|
-
try:
|
|
228
|
-
next_fd = _open_directory_at(dir_fd, component, path)
|
|
229
|
-
except FileNotFoundError:
|
|
230
|
-
if not create:
|
|
231
|
-
raise
|
|
232
|
-
try:
|
|
233
|
-
_mkdir_directory_at(dir_fd, component)
|
|
234
|
-
except FileExistsError:
|
|
235
|
-
# 다른 hook process 가 방금 만든 경우. 아래 no-follow open 으로
|
|
236
|
-
# 실제 디렉터리인지 다시 검증하므로 symlink race 는 허용하지 않는다.
|
|
237
|
-
pass
|
|
238
|
-
next_fd = _open_directory_at(dir_fd, component, path)
|
|
239
|
-
os.close(dir_fd)
|
|
240
|
-
dir_fd = next_fd
|
|
241
|
-
return dir_fd
|
|
242
|
-
except Exception:
|
|
243
|
-
os.close(dir_fd)
|
|
244
|
-
raise
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
def _open_regular_no_symlink(
|
|
248
|
-
path: Path,
|
|
249
|
-
flags: int | None = None,
|
|
250
|
-
mode: int = 0o666,
|
|
251
|
-
*,
|
|
252
|
-
create_parent: bool = False,
|
|
253
|
-
) -> int:
|
|
254
|
-
if os.open not in os.supports_dir_fd:
|
|
255
|
-
raise UnsupportedSafeStateIOError(
|
|
256
|
-
UNSUPPORTED_STATE_IO_ERRNO,
|
|
257
|
-
"failed-attempt nudge state requires directory-relative no-follow opens",
|
|
258
|
-
)
|
|
259
|
-
path = _normalize_allowed_first_absolute_symlink(path)
|
|
260
|
-
parent_fd = _ensure_directory_no_symlink(path.parent, create=create_parent)
|
|
261
|
-
open_flags = (flags if flags is not None else _base_open_flags()) | _no_follow_flag()
|
|
262
|
-
try:
|
|
263
|
-
fd = os.open(path.name, open_flags, mode, dir_fd=parent_fd)
|
|
264
|
-
try:
|
|
265
|
-
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
266
|
-
raise UnsafeStatePathError(errno.EINVAL, "not a regular file", str(path))
|
|
267
|
-
return fd
|
|
268
|
-
except Exception:
|
|
269
|
-
os.close(fd)
|
|
270
|
-
raise
|
|
271
|
-
finally:
|
|
272
|
-
os.close(parent_fd)
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
def _read_text_no_follow(path: Path) -> str:
|
|
276
|
-
fd = _open_regular_no_symlink(path)
|
|
277
|
-
try:
|
|
278
|
-
with os.fdopen(fd, "r", encoding="utf-8") as handle:
|
|
279
|
-
fd = -1
|
|
280
|
-
return handle.read()
|
|
281
|
-
finally:
|
|
282
|
-
if fd != -1:
|
|
283
|
-
os.close(fd)
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
def _is_unsafe_state_path_error(exc: OSError) -> bool:
|
|
287
|
-
return isinstance(exc, UnsafeStatePathError) or exc.errno in UNSAFE_STATE_PATH_ERRNOS
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
def _rename_supports_dir_fd() -> bool:
|
|
291
|
-
return os.rename in os.supports_dir_fd
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
def _rename_with_dir_fd(src: str, dst: str, parent_fd: int) -> None:
|
|
295
|
-
os.rename(src, dst, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
def _rename_state_entry(src: str, dst: str, parent_fd: int) -> None:
|
|
299
|
-
if not _rename_supports_dir_fd():
|
|
300
|
-
raise UnsupportedSafeStateIOError(
|
|
301
|
-
UNSUPPORTED_STATE_IO_ERRNO,
|
|
302
|
-
"failed-attempt nudge state requires directory-relative rename",
|
|
303
|
-
)
|
|
304
|
-
try:
|
|
305
|
-
_rename_with_dir_fd(src, dst, parent_fd)
|
|
306
|
-
except (NotImplementedError, TypeError) as exc:
|
|
307
|
-
raise UnsupportedSafeStateIOError(
|
|
308
|
-
UNSUPPORTED_STATE_IO_ERRNO,
|
|
309
|
-
"failed-attempt nudge state requires directory-relative rename",
|
|
310
|
-
) from exc
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
def load_entries(path: Path) -> list[dict]:
|
|
314
|
-
"""state file 을 읽는다. 파일이 symlink/regular 가 아니거나 손상되면 빈 list 반환."""
|
|
315
|
-
try:
|
|
316
|
-
data = json.loads(_read_text_no_follow(path))
|
|
317
|
-
except FileNotFoundError:
|
|
318
|
-
return []
|
|
319
|
-
except UnicodeDecodeError:
|
|
320
|
-
return []
|
|
321
|
-
except json.JSONDecodeError:
|
|
322
|
-
return []
|
|
323
|
-
except UnsupportedSafeStateIOError:
|
|
324
|
-
raise
|
|
325
|
-
except OSError as exc:
|
|
326
|
-
if _is_unsafe_state_path_error(exc):
|
|
327
|
-
return []
|
|
328
|
-
raise
|
|
329
|
-
if not isinstance(data, list):
|
|
330
|
-
return []
|
|
331
|
-
return [entry for entry in data if isinstance(entry, dict)]
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
def save_entries(path: Path, entries: list[dict]) -> None:
|
|
335
|
-
"""심볼릭 링크 / 동시 race 에 안전한 atomic write.
|
|
336
|
-
|
|
337
|
-
- 부모/조상 디렉터리를 dir_fd + O_NOFOLLOW 로 열어 symlink/race 를 거부한다.
|
|
338
|
-
- O_CREAT|O_EXCL|O_WRONLY|O_NOFOLLOW 로 임시 파일을 쓰고 dir_fd 기반 rename 으로 교체.
|
|
339
|
-
- 임시 파일 이름은 무작위라 동시 호출 충돌 가능성이 낮고 O_EXCL 로 재확인한다.
|
|
340
|
-
- 모드는 0o600 으로 잠근다.
|
|
341
|
-
"""
|
|
342
|
-
parent_fd = -1
|
|
343
|
-
tmp_fd = -1
|
|
344
|
-
tmp_name = f".nudge-{os.getpid()}-{uuid.uuid4().hex}.json.tmp"
|
|
345
|
-
try:
|
|
346
|
-
parent_fd = _ensure_directory_no_symlink(path.parent, create=True)
|
|
347
|
-
tmp_fd = os.open(
|
|
348
|
-
tmp_name,
|
|
349
|
-
os.O_CREAT | os.O_EXCL | os.O_WRONLY | _no_follow_flag(),
|
|
350
|
-
0o600,
|
|
351
|
-
dir_fd=parent_fd,
|
|
352
|
-
)
|
|
353
|
-
try:
|
|
354
|
-
if hasattr(os, "fchmod"):
|
|
355
|
-
os.fchmod(tmp_fd, 0o600)
|
|
356
|
-
with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
|
|
357
|
-
tmp_fd = -1
|
|
358
|
-
f.write(json.dumps(entries, ensure_ascii=False))
|
|
359
|
-
finally:
|
|
360
|
-
if tmp_fd != -1:
|
|
361
|
-
os.close(tmp_fd)
|
|
362
|
-
|
|
363
|
-
# 기존 state file 이 symlink/비정규 파일이면 거부. 이후 이름이 바뀌어도
|
|
364
|
-
# dir_fd 기반 replace 는 symlink 타깃을 따라가지 않고 해당 dir entry 만 교체한다.
|
|
365
|
-
try:
|
|
366
|
-
existing_fd = os.open(path.name, _base_open_flags() | _no_follow_flag(), dir_fd=parent_fd)
|
|
367
|
-
except FileNotFoundError:
|
|
368
|
-
existing_fd = -1
|
|
369
|
-
except OSError as exc:
|
|
370
|
-
if _is_unsafe_state_path_error(exc):
|
|
371
|
-
return
|
|
372
|
-
raise
|
|
373
|
-
else:
|
|
374
|
-
try:
|
|
375
|
-
if not stat.S_ISREG(os.fstat(existing_fd).st_mode):
|
|
376
|
-
return
|
|
377
|
-
finally:
|
|
378
|
-
os.close(existing_fd)
|
|
379
|
-
|
|
380
|
-
_rename_state_entry(tmp_name, path.name, parent_fd)
|
|
381
|
-
tmp_name = ""
|
|
382
|
-
except UnsupportedSafeStateIOError:
|
|
383
|
-
raise
|
|
384
|
-
except OSError as exc:
|
|
385
|
-
if _is_unsafe_state_path_error(exc):
|
|
386
|
-
return
|
|
387
|
-
raise
|
|
388
|
-
finally:
|
|
389
|
-
if tmp_fd != -1:
|
|
390
|
-
try:
|
|
391
|
-
os.close(tmp_fd)
|
|
392
|
-
except OSError:
|
|
393
|
-
pass
|
|
394
|
-
if parent_fd != -1:
|
|
395
|
-
if tmp_name:
|
|
396
|
-
try:
|
|
397
|
-
os.unlink(tmp_name, dir_fd=parent_fd)
|
|
398
|
-
except OSError:
|
|
399
|
-
pass
|
|
400
|
-
try:
|
|
401
|
-
os.close(parent_fd)
|
|
402
|
-
except OSError:
|
|
403
|
-
pass
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
def safe_session_label(session_id: str | None) -> str | None:
|
|
407
|
-
"""session_id 를 파일명 안전 digest 로 변환. 없으면 None — 호출자가 hook 을 noop 한다."""
|
|
408
|
-
if not session_id or not isinstance(session_id, str):
|
|
409
|
-
return None
|
|
410
|
-
digest = hashlib.sha256(session_id.encode("utf-8", errors="replace")).hexdigest()[:16]
|
|
411
|
-
return f"sess-{digest}"
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
def diagnostic_text(exc: OSError) -> str:
|
|
415
|
-
"""Bound hook stderr diagnostics so hostile session/path text is not surfaced raw."""
|
|
416
|
-
text = str(exc) or exc.__class__.__name__
|
|
417
|
-
text = ANSI_ESCAPE_RE.sub(" ", text)
|
|
418
|
-
text = CONTROL_CHAR_RE.sub(" ", text)
|
|
419
|
-
text = redact_sensitive_hook_text(text)
|
|
420
|
-
cwd = ""
|
|
421
|
-
try:
|
|
422
|
-
cwd = str(Path.cwd().resolve())
|
|
423
|
-
except OSError:
|
|
424
|
-
try:
|
|
425
|
-
cwd = str(Path.cwd())
|
|
426
|
-
except OSError:
|
|
427
|
-
cwd = ""
|
|
428
|
-
if cwd and cwd not in {"/", "\\"}:
|
|
429
|
-
text = text.replace(cwd, "<cwd>")
|
|
430
|
-
compact = " ".join(text.split())
|
|
431
|
-
if len(compact) > DIAGNOSTIC_MAX_CHARS:
|
|
432
|
-
compact = compact[: DIAGNOSTIC_MAX_CHARS - 15].rstrip() + "...[truncated]"
|
|
433
|
-
return compact or exc.__class__.__name__
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
def extract_exit_code(tool_response: dict) -> int | None:
|
|
437
|
-
for key in ("exitCode", "exit_code", "returncode"):
|
|
438
|
-
value = tool_response.get(key)
|
|
439
|
-
if isinstance(value, bool):
|
|
440
|
-
continue
|
|
441
|
-
if isinstance(value, int):
|
|
442
|
-
return value
|
|
443
|
-
return None
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
def read_bounded_stdin_text(limit: int = MAX_HOOK_STDIN_BYTES) -> tuple[str | None, bool]:
|
|
447
|
-
stream = getattr(sys.stdin, "buffer", sys.stdin)
|
|
448
|
-
data = stream.read(limit + 1)
|
|
449
|
-
if isinstance(data, str):
|
|
450
|
-
oversized = len(data.encode("utf-8", errors="replace")) > limit
|
|
451
|
-
return (None, True) if oversized else (data, False)
|
|
452
|
-
oversized = len(data) > limit
|
|
453
|
-
if oversized:
|
|
454
|
-
return None, True
|
|
455
|
-
return data.decode("utf-8", errors="replace"), False
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
def update_entries(entries: list[dict], fp: str, success: bool) -> list[dict]:
|
|
459
|
-
"""성공한 fingerprint 는 카운트 리셋. 실패는 append.
|
|
460
|
-
|
|
461
|
-
리셋 의미: 같은 fp 의 마지막 연속 실패 streak 을 끊는다. 다음 동일 fp 실패는 1 회로
|
|
462
|
-
재시작되어 fail→success→fail 패턴이 잘못 nudge 되지 않는다.
|
|
463
|
-
"""
|
|
464
|
-
if success:
|
|
465
|
-
# 마지막 entry 가 같은 fp 이면 streak 을 끊기 위해 dummy 'ok' marker 를 push.
|
|
466
|
-
entries.append({"fp": fp, "ok": True})
|
|
467
|
-
else:
|
|
468
|
-
entries.append({"fp": fp})
|
|
469
|
-
if len(entries) > MAX_TRACKED:
|
|
470
|
-
entries = entries[-MAX_TRACKED:]
|
|
471
|
-
return entries
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
def count_consecutive_failures(entries: list[dict], fp: str) -> int:
|
|
475
|
-
"""tail 에서 같은 fp 의 연속 실패 카운트. ok marker 또는 다른 fp 를 만나면 멈춘다."""
|
|
476
|
-
consecutive = 0
|
|
477
|
-
for entry in reversed(entries):
|
|
478
|
-
if entry.get("fp") != fp:
|
|
479
|
-
break
|
|
480
|
-
if entry.get("ok"):
|
|
481
|
-
break
|
|
482
|
-
consecutive += 1
|
|
483
|
-
return consecutive
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
def main() -> int:
|
|
487
|
-
raw_payload, oversized = read_bounded_stdin_text()
|
|
488
|
-
if oversized:
|
|
489
|
-
sys.stderr.write("context-guard-failed-nudge: oversized hook JSON skipped\n")
|
|
490
|
-
print("{}")
|
|
491
|
-
return 0
|
|
492
|
-
try:
|
|
493
|
-
payload = json.loads(raw_payload or "")
|
|
494
|
-
except json.JSONDecodeError:
|
|
495
|
-
print("{}")
|
|
496
|
-
return 0
|
|
497
|
-
if not isinstance(payload, dict):
|
|
498
|
-
print("{}")
|
|
499
|
-
return 0
|
|
500
|
-
|
|
501
|
-
tool_name = payload.get("tool_name") or payload.get("toolName")
|
|
502
|
-
if tool_name != "Bash":
|
|
503
|
-
print("{}")
|
|
504
|
-
return 0
|
|
505
|
-
|
|
506
|
-
tool_input = payload.get("tool_input") or payload.get("toolInput") or {}
|
|
507
|
-
tool_response = payload.get("tool_response") or payload.get("toolResponse") or {}
|
|
508
|
-
if not isinstance(tool_input, dict) or not isinstance(tool_response, dict):
|
|
509
|
-
print("{}")
|
|
510
|
-
return 0
|
|
511
|
-
|
|
512
|
-
command = tool_input.get("command")
|
|
513
|
-
if not isinstance(command, str) or not command.strip():
|
|
514
|
-
print("{}")
|
|
515
|
-
return 0
|
|
516
|
-
|
|
517
|
-
exit_code = extract_exit_code(tool_response)
|
|
518
|
-
if exit_code is None:
|
|
519
|
-
# exit_code 미확정 — 실패 여부를 모르므로 회귀 위험 방지 차원에서 noop.
|
|
520
|
-
print("{}")
|
|
521
|
-
return 0
|
|
522
|
-
|
|
523
|
-
session = safe_session_label(payload.get("session_id") or payload.get("sessionId"))
|
|
524
|
-
if session is None:
|
|
525
|
-
# session_id 가 없으면 cross-session 오염 위험으로 그냥 noop. 상태 파일도 만들지 않는다.
|
|
526
|
-
print("{}")
|
|
527
|
-
return 0
|
|
528
|
-
|
|
529
|
-
fp = fingerprint(normalize_command(command))
|
|
530
|
-
state_path = STATE_DIR / STATE_FILE_TEMPLATE.format(session=session)
|
|
531
|
-
|
|
532
|
-
try:
|
|
533
|
-
entries = load_entries(state_path)
|
|
534
|
-
except OSError as exc:
|
|
535
|
-
# state 읽기 실패해도 실행을 막지 않는다. 진단 신호만 stderr 에 남긴 뒤 새 streak 으로 시작한다.
|
|
536
|
-
sys.stderr.write(f"context-guard-failed-nudge: state read skipped: {diagnostic_text(exc)}\n")
|
|
537
|
-
entries = []
|
|
538
|
-
success = exit_code == 0
|
|
539
|
-
entries = update_entries(entries, fp, success)
|
|
540
|
-
try:
|
|
541
|
-
save_entries(state_path, entries)
|
|
542
|
-
except OSError as exc:
|
|
543
|
-
# state 저장 실패해도 실행을 막지 않는다. 진단 신호만 stderr 에 남긴다.
|
|
544
|
-
sys.stderr.write(f"context-guard-failed-nudge: state write skipped: {diagnostic_text(exc)}\n")
|
|
545
|
-
|
|
546
|
-
if success:
|
|
547
|
-
# 성공이면 nudge 는 절대 발화하지 않는다.
|
|
548
|
-
print("{}")
|
|
549
|
-
return 0
|
|
550
|
-
|
|
551
|
-
consecutive = count_consecutive_failures(entries, fp)
|
|
552
|
-
if consecutive < MIN_CONSECUTIVE:
|
|
553
|
-
print("{}")
|
|
554
|
-
return 0
|
|
555
|
-
|
|
556
|
-
response = {
|
|
557
|
-
"hookSpecificOutput": {
|
|
558
|
-
"hookEventName": "PostToolUse",
|
|
559
|
-
"additionalContext": NUDGE_TEXT + (STRATEGY_SWITCH_TEXT if consecutive >= STRATEGY_SWITCH_MIN_CONSECUTIVE else ""),
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
print(json.dumps(response, ensure_ascii=False))
|
|
563
|
-
return 0
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
if __name__ == "__main__":
|
|
567
|
-
raise SystemExit(main())
|