@ictechgy/context-guard 0.4.0

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 (71) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +4 -0
  4. package/README.ko.md +353 -0
  5. package/README.md +353 -0
  6. package/context-guard-kit/README.md +76 -0
  7. package/context-guard-kit/benchmark_runner.py +1898 -0
  8. package/context-guard-kit/claude_transcript_cost_audit.py +1591 -0
  9. package/context-guard-kit/context_compress.py +543 -0
  10. package/context-guard-kit/context_escrow.py +919 -0
  11. package/context-guard-kit/context_guard_cli.py +149 -0
  12. package/context-guard-kit/context_guard_diet.py +1036 -0
  13. package/context-guard-kit/context_pack.py +929 -0
  14. package/context-guard-kit/failed_attempt_nudge.py +567 -0
  15. package/context-guard-kit/guard_large_read.py +690 -0
  16. package/context-guard-kit/hook_secret_patterns.py +43 -0
  17. package/context-guard-kit/read_symbol.py +483 -0
  18. package/context-guard-kit/rewrite_bash_for_token_budget.py +501 -0
  19. package/context-guard-kit/sanitize_output.py +725 -0
  20. package/context-guard-kit/settings.example.json +67 -0
  21. package/context-guard-kit/setup_wizard.py +1724 -0
  22. package/context-guard-kit/statusline.sh +362 -0
  23. package/context-guard-kit/statusline_merged.sh +157 -0
  24. package/context-guard-kit/tool_schema_pruner.py +837 -0
  25. package/context-guard-kit/trim_command_output.py +1098 -0
  26. package/docs/distribution.md +55 -0
  27. package/package.json +70 -0
  28. package/packaging/homebrew/context-guard.rb.template +34 -0
  29. package/plugins/context-guard/.claude-plugin/plugin.json +41 -0
  30. package/plugins/context-guard/LICENSE +201 -0
  31. package/plugins/context-guard/NOTICE +4 -0
  32. package/plugins/context-guard/README.ko.md +135 -0
  33. package/plugins/context-guard/README.md +135 -0
  34. package/plugins/context-guard/bin/claude-read-symbol +6 -0
  35. package/plugins/context-guard/bin/claude-sanitize-output +6 -0
  36. package/plugins/context-guard/bin/claude-token-artifact +6 -0
  37. package/plugins/context-guard/bin/claude-token-audit +6 -0
  38. package/plugins/context-guard/bin/claude-token-bench +6 -0
  39. package/plugins/context-guard/bin/claude-token-diet +6 -0
  40. package/plugins/context-guard/bin/claude-token-failed-nudge +6 -0
  41. package/plugins/context-guard/bin/claude-token-guard-read +6 -0
  42. package/plugins/context-guard/bin/claude-token-rewrite-bash +6 -0
  43. package/plugins/context-guard/bin/claude-token-setup +6 -0
  44. package/plugins/context-guard/bin/claude-token-statusline +6 -0
  45. package/plugins/context-guard/bin/claude-token-statusline-merged +6 -0
  46. package/plugins/context-guard/bin/claude-trim-output +6 -0
  47. package/plugins/context-guard/bin/context-guard +149 -0
  48. package/plugins/context-guard/bin/context-guard-artifact +919 -0
  49. package/plugins/context-guard/bin/context-guard-audit +1591 -0
  50. package/plugins/context-guard/bin/context-guard-bench +1898 -0
  51. package/plugins/context-guard/bin/context-guard-compress +543 -0
  52. package/plugins/context-guard/bin/context-guard-diet +1036 -0
  53. package/plugins/context-guard/bin/context-guard-failed-nudge +567 -0
  54. package/plugins/context-guard/bin/context-guard-guard-read +690 -0
  55. package/plugins/context-guard/bin/context-guard-pack +929 -0
  56. package/plugins/context-guard/bin/context-guard-read-symbol +483 -0
  57. package/plugins/context-guard/bin/context-guard-rewrite-bash +501 -0
  58. package/plugins/context-guard/bin/context-guard-sanitize-output +725 -0
  59. package/plugins/context-guard/bin/context-guard-setup +1724 -0
  60. package/plugins/context-guard/bin/context-guard-statusline +362 -0
  61. package/plugins/context-guard/bin/context-guard-statusline-merged +157 -0
  62. package/plugins/context-guard/bin/context-guard-tool-prune +837 -0
  63. package/plugins/context-guard/bin/context-guard-trim-output +1098 -0
  64. package/plugins/context-guard/brief/README.md +65 -0
  65. package/plugins/context-guard/brief/brief-mode.lite.md +29 -0
  66. package/plugins/context-guard/brief/brief-mode.standard.md +31 -0
  67. package/plugins/context-guard/brief/brief-mode.ultra.md +32 -0
  68. package/plugins/context-guard/lib/hook_secret_patterns.py +43 -0
  69. package/plugins/context-guard/skills/audit/SKILL.md +39 -0
  70. package/plugins/context-guard/skills/optimize/SKILL.md +48 -0
  71. package/plugins/context-guard/skills/setup/SKILL.md +40 -0
@@ -0,0 +1,567 @@
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())