@ictechgy/context-guard 0.4.15 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.ko.md +128 -2
  3. package/README.md +144 -3
  4. package/docs/distribution.md +100 -0
  5. package/package.json +4 -1
  6. package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
  7. package/plugins/context-guard/README.ko.md +43 -1
  8. package/plugins/context-guard/README.md +44 -1
  9. package/plugins/context-guard/bin/bash_reference_policy.py +967 -0
  10. package/plugins/context-guard/bin/context-guard-artifact +90 -9
  11. package/plugins/context-guard/bin/context-guard-audit +169 -66
  12. package/plugins/context-guard/bin/context-guard-bench +9865 -211
  13. package/plugins/context-guard/bin/context-guard-compress +90 -8
  14. package/plugins/context-guard/bin/context-guard-diet +1 -7
  15. package/plugins/context-guard/bin/context-guard-experiments +5 -1
  16. package/plugins/context-guard/bin/context-guard-failed-nudge +777 -83
  17. package/plugins/context-guard/bin/context-guard-guard-read +496 -57
  18. package/plugins/context-guard/bin/context-guard-mcp +2 -1
  19. package/plugins/context-guard/bin/context-guard-pack +1570 -150
  20. package/plugins/context-guard/bin/context-guard-read-symbol +7 -2
  21. package/plugins/context-guard/bin/context-guard-rewrite-bash +2669 -236
  22. package/plugins/context-guard/bin/context-guard-sanitize-output +723 -92
  23. package/plugins/context-guard/bin/context-guard-setup +1944 -222
  24. package/plugins/context-guard/bin/context-guard-statusline +163 -55
  25. package/plugins/context-guard/bin/context-guard-statusline-merged +78 -23
  26. package/plugins/context-guard/bin/context-guard-tool-prune +44 -11
  27. package/plugins/context-guard/bin/context-guard-trim-output +795 -48
  28. package/plugins/context-guard/brief/README.md +19 -0
  29. package/plugins/context-guard/brief/narration-mode.quiet.md +21 -0
  30. package/plugins/context-guard/lib/context_guard_commands.py +10 -2
  31. package/plugins/context-guard/lib/credential_policy.py +185 -0
  32. package/plugins/context-guard/lib/transcript_usage_reducer.py +378 -0
@@ -1,31 +1,32 @@
1
1
  #!/usr/bin/env python3
2
- """Claude Code PostToolUse hook: 동일 Bash 명령이 연속 실패하면 /clear 권유.
2
+ """Claude Code Bash terminal-hook feedback with privacy-safe accounting.
3
3
 
4
- 같은 명령으로 연속 실패하면 흐름은 컨텍스트 오염을 일으키고 prompt cache 도
5
- retry 마다 재워밍된다. hook 패턴을 감지해 다음 turn 의 추가 컨텍스트로
6
- 짧은 모델 힌트를 주입한다 (블록하지 않음).
4
+ The hook accepts both ``PostToolUse`` and ``PostToolUseFailure`` payloads. It
5
+ groups only exact ContextGuard wrapper envelopes, stores full SHA-256 identity
6
+ components instead of raw commands/session/tool IDs, and emits one bounded
7
+ strategy nudge on the second unique failure in an episode.
7
8
 
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".
9
+ State is project-local at ``.context-guard/failures-v2.json``. A
10
+ symlink-safe advisory lock covers read/modify/durable-write so concurrent hook
11
+ processes cannot emit the same episode twice.
17
12
  """
18
13
  from __future__ import annotations
19
14
 
15
+ from contextlib import contextmanager
16
+ from dataclasses import dataclass
20
17
  import errno
18
+ import fcntl
21
19
  import hashlib
22
20
  import importlib.util
23
21
  import json
22
+ import math
24
23
  import os
25
24
  import re
26
25
  import shlex
26
+ import shutil
27
27
  import stat
28
28
  import sys
29
+ import time
29
30
  import uuid
30
31
  from pathlib import Path
31
32
 
@@ -52,7 +53,51 @@ _hook_secret_patterns = _load_hook_secret_patterns()
52
53
  redact_sensitive_hook_text = _hook_secret_patterns.redact_sensitive_hook_text
53
54
 
54
55
  STATE_DIR = Path(".context-guard")
55
- STATE_FILE_TEMPLATE = "failures-{session}.json"
56
+ STATE_PATH = STATE_DIR / "failures-v2.json"
57
+ STATE_LOCK_PATH = STATE_DIR / "failures-v2.lock"
58
+ STATE_VERSION = 2
59
+ STATE_TTL_SECONDS = 30 * 60
60
+ MAX_EPISODES = 256
61
+ MAX_EVENT_IDS = 512
62
+ MAX_STATE_BYTES = 1_000_000
63
+ MAX_COUNTER = (1 << 63) - 1
64
+ STATE_LOCK_TIMEOUT_SECONDS = 2.0
65
+ STATE_LOCK_POLL_SECONDS = 0.01
66
+ CGW1_SENTINEL = "--context-guard-wrapper-v1"
67
+ CGW1_COMMAND_SEARCH_DIFF = "command_search_diff"
68
+ CGW1_SHELL_ARGV = ("bash", "-c")
69
+ LEGACY_V0_MAX_LINES = "220"
70
+ PROTOCOL_CGW1 = "cgw1"
71
+ PROTOCOL_LEGACY_V0 = "legacy-v0"
72
+ PROTOCOL_DIRECT = "direct"
73
+ PROTOCOL_FOREIGN = "legacy-or-foreign"
74
+ PROTOCOLS = frozenset({
75
+ PROTOCOL_CGW1,
76
+ PROTOCOL_LEGACY_V0,
77
+ PROTOCOL_DIRECT,
78
+ PROTOCOL_FOREIGN,
79
+ })
80
+ COUNTER_NAMES = frozenset({
81
+ "dedupe",
82
+ "conflict",
83
+ "episode_expired",
84
+ "event_expired",
85
+ "episode_evicted",
86
+ "event_evicted",
87
+ "tracking_started",
88
+ "nudge_emitted",
89
+ "failure_after_emit",
90
+ "success_reset",
91
+ "interrupted",
92
+ "missing_exit",
93
+ "ambiguous_exit",
94
+ "malformed_event",
95
+ "missing_session",
96
+ "missing_tool_id",
97
+ })
98
+
99
+ # Retained compatibility helpers are exercised by the legacy aggregate suite.
100
+ # The v2 runtime below does not use their truncated fingerprints or tail list.
56
101
  MAX_TRACKED = 5
57
102
  MIN_CONSECUTIVE = 2
58
103
  STRATEGY_SWITCH_MIN_CONSECUTIVE = 3
@@ -84,18 +129,45 @@ class UnsafeStatePathError(OSError):
84
129
  """state path 가 symlink/비정규 파일/부적절한 경로 형태라 거부됨."""
85
130
 
86
131
 
132
+ class InvalidStateError(OSError):
133
+ """Persisted v2 state is malformed, unsupported, or oversized."""
134
+
135
+
136
+ class StateLockTimeoutError(OSError):
137
+ """The bounded state lock deadline elapsed."""
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class CommandIdentity:
142
+ protocol: str
143
+ digest: str
144
+
145
+
146
+ @dataclass(frozen=True)
147
+ class TerminalEvent:
148
+ hook_event_name: str
149
+ outcome: str
150
+ session_digest: str
151
+ tool_id_digest: str
152
+ command_identity: CommandIdentity
153
+
154
+ @property
155
+ def episode_key(self) -> str:
156
+ components = (
157
+ self.session_digest,
158
+ self.command_identity.protocol,
159
+ self.command_identity.digest,
160
+ )
161
+ return sha256_text(json.dumps(components, separators=(",", ":"), ensure_ascii=True))
162
+
163
+
87
164
  # additionalContext 는 모델에게 주입되므로 사용자에게 직접 명령하는 톤보다 모델이 행동을
88
165
  # 결정할 때 참고할 힌트 형태가 자연스럽다. 모델이 사용자에게 안내하도록 유도한다.
89
166
  NUDGE_TEXT = (
90
- "AI 힌트: 동일 Bash 명령이 이 세션에서 연속 두 번 실패했습니다. "
91
- "이는 현재 접근 방식이 같은 방향으로 막혀 있고, 실패 시도가 누적될수록 컨텍스트가 오염되며 "
92
- "prompt cache retry 마다 재워밍됨을 의미합니다. "
93
- "재시도 전에 사용자에게 `/clear` 또는 `/compact focus on …` 으로 세션을 정리한 뒤 "
94
- "재현 명령·기대 결과·금지 사항을 더 좁혀 다시 prompt 하도록 안내하거나, "
95
- "근본적으로 다른 방향(다른 모듈 / 검증 명령 / 더 작은 재현)을 제안하세요. "
96
- "직전 출력에 artifact_receipt 또는 contextguard-artifact:<id> 핸들이 있으면, 전체 로그를 다시 붙여넣거나 "
97
- "동일한 broad 명령을 재실행하기 전에 context-guard-artifact receipt/get/search 로 필요한 줄·패턴만 "
98
- "정확히 rehydrate 하도록 우선 제안하세요."
167
+ "AI 힌트: 같은 Bash 작업이 이 세션에서 두 번 실패했습니다. 동일 경로를 다시 실행하지 말고 "
168
+ "실패의 공통 조건을 요약한 다른 가설, 작은 재현, 또는 수정 후의 좁은 검증으로 전환하세요. "
169
+ " 출력이 artifact receipt로 저장되었다면 전체 로그를 재주입하지 말고 필요한 줄만 조회하세요. "
170
+ "컨텍스트가 오염되었다면 사용자에게 `/compact` 또는 `/clear` 선택지를 짧게 안내하세요."
99
171
  )
100
172
  STRATEGY_SWITCH_TEXT = (
101
173
  " Strategy-switch signal: the same failure direction has now repeated at least three times. "
@@ -148,6 +220,165 @@ def fingerprint(normalized: str) -> str:
148
220
  return hashlib.sha256(normalized.encode("utf-8", errors="replace")).hexdigest()[:16]
149
221
 
150
222
 
223
+ def sha256_text(value: str) -> str:
224
+ return hashlib.sha256(value.encode("utf-8", errors="surrogatepass")).hexdigest()
225
+
226
+
227
+ def _wrapper_prefixes() -> dict[str, tuple[str, ...]]:
228
+ """Return only the wrapper identities emitted beside this nudge helper."""
229
+ if Path(__file__).name == "failed_attempt_nudge.py":
230
+ return {
231
+ "sanitize": ("python3", str(SCRIPT_DIR / "sanitize_output.py")),
232
+ "trim": ("python3", str(SCRIPT_DIR / "trim_command_output.py")),
233
+ }
234
+ return {
235
+ "sanitize": (str(SCRIPT_DIR / "context-guard-sanitize-output"),),
236
+ "trim": (str(SCRIPT_DIR / "context-guard-trim-output"),),
237
+ }
238
+
239
+
240
+ def _approved_python_runtime() -> str:
241
+ canonical = os.path.realpath(sys.executable)
242
+ if not canonical or not os.path.isabs(canonical) or not os.path.isfile(canonical) or not os.access(canonical, os.X_OK):
243
+ raise RuntimeError("approved Python runtime is unavailable")
244
+ return canonical
245
+
246
+
247
+ def _approved_bash_runtime() -> str:
248
+ found = shutil.which("bash", path=os.defpath)
249
+ if not found:
250
+ raise RuntimeError("approved Bash runtime is unavailable")
251
+ canonical = os.path.realpath(found)
252
+ if not os.path.isfile(canonical) or not os.access(canonical, os.X_OK):
253
+ raise RuntimeError("approved Bash runtime is unavailable")
254
+ return canonical
255
+
256
+
257
+ def _approved_env_runtime() -> str:
258
+ found = shutil.which("env", path=os.defpath)
259
+ if not found:
260
+ raise RuntimeError("approved env runtime is unavailable")
261
+ canonical = os.path.realpath(found)
262
+ if not os.path.isfile(canonical) or not os.access(canonical, os.X_OK):
263
+ raise RuntimeError("approved env runtime is unavailable")
264
+ return canonical
265
+
266
+
267
+ def _runtime_shell_argv() -> tuple[str, ...]:
268
+ return (
269
+ _approved_env_runtime(),
270
+ "-u", "BASH_ENV",
271
+ "-u", "ENV",
272
+ "-u", "PYTHONHOME",
273
+ "-u", "PYTHONPATH",
274
+ "-u", "PYTHONSTARTUP",
275
+ "-u", "SHELLOPTS",
276
+ "-u", "BASHOPTS",
277
+ "-u", "PS4",
278
+ _approved_bash_runtime(),
279
+ "--noprofile",
280
+ "--norc",
281
+ "-p",
282
+ "-c",
283
+ )
284
+
285
+
286
+ def _runtime_wrapper_prefixes() -> dict[str, tuple[str, ...]]:
287
+ adjacent = _wrapper_prefixes()
288
+ return {
289
+ kind: (_approved_python_runtime(), "-I", prefix[-1])
290
+ for kind, prefix in adjacent.items()
291
+ }
292
+
293
+
294
+ def _argv(command: str) -> tuple[str, ...] | None:
295
+ try:
296
+ values = shlex.split(command, posix=True)
297
+ except (ValueError, TypeError):
298
+ return None
299
+ if not values or any("\x00" in value for value in values):
300
+ return None
301
+ return tuple(values)
302
+
303
+
304
+ def _is_wrapper_shaped(argv: tuple[str, ...]) -> bool:
305
+ known = {
306
+ "sanitize_output.py",
307
+ "trim_command_output.py",
308
+ "context-guard-sanitize-output",
309
+ "context-guard-trim-output",
310
+ "claude-sanitize-output",
311
+ "claude-trim-output",
312
+ }
313
+ if not argv:
314
+ return False
315
+ first = os.path.basename(argv[0])
316
+ if first in known:
317
+ return True
318
+ return (
319
+ re.fullmatch(r"python(?:\d+(?:\.\d+)?)?", first) is not None
320
+ and len(argv) > 1
321
+ and (
322
+ os.path.basename(argv[1]) in known
323
+ or (
324
+ len(argv) > 2
325
+ and argv[1] == "-I"
326
+ and os.path.basename(argv[2]) in known
327
+ )
328
+ )
329
+ )
330
+
331
+
332
+ def command_identity(command: str) -> CommandIdentity:
333
+ """Structurally unwrap only the frozen current or allowlisted v0 shapes."""
334
+ argv = _argv(command)
335
+ if argv is None:
336
+ return CommandIdentity(PROTOCOL_FOREIGN, sha256_text(command))
337
+
338
+ prefixes = _wrapper_prefixes()
339
+ runtime_prefixes = _runtime_wrapper_prefixes()
340
+ current_shell_argv = _runtime_shell_argv()
341
+ for prefix, shell_argv in (
342
+ (runtime_prefixes["sanitize"], current_shell_argv),
343
+ (prefixes["sanitize"], CGW1_SHELL_ARGV),
344
+ ):
345
+ sanitize_cgw1 = prefix + (
346
+ CGW1_SENTINEL,
347
+ CGW1_COMMAND_SEARCH_DIFF,
348
+ "--",
349
+ *shell_argv,
350
+ )
351
+ if len(argv) == len(sanitize_cgw1) + 1 and argv[:-1] == sanitize_cgw1:
352
+ logical = argv[-1]
353
+ if logical and not _is_wrapper_shaped(_argv(logical) or ()):
354
+ return CommandIdentity(PROTOCOL_CGW1, sha256_text(logical))
355
+
356
+ # The frozen A1 producer still emits this exact v0 envelope for trim.
357
+ # The historical producer emitted it for sanitize as well, so both known
358
+ # adjacent wrapper identities remain allowlisted for one compatibility
359
+ # window. No other --/bash/-lc spelling is unwrapped.
360
+ for prefix_set, shell_argv in (
361
+ (prefixes, CGW1_SHELL_ARGV),
362
+ (runtime_prefixes, current_shell_argv),
363
+ ):
364
+ for prefix in prefix_set.values():
365
+ legacy_v0 = prefix + (
366
+ "--max-lines",
367
+ LEGACY_V0_MAX_LINES,
368
+ "--",
369
+ *shell_argv,
370
+ )
371
+ if len(argv) == len(legacy_v0) + 1 and argv[:-1] == legacy_v0:
372
+ logical = argv[-1]
373
+ if logical and not _is_wrapper_shaped(_argv(logical) or ()):
374
+ return CommandIdentity(PROTOCOL_LEGACY_V0, sha256_text(logical))
375
+
376
+ protocol = PROTOCOL_FOREIGN if (
377
+ _is_wrapper_shaped(argv) or CGW1_SENTINEL in argv
378
+ ) else PROTOCOL_DIRECT
379
+ return CommandIdentity(protocol, sha256_text(command))
380
+
381
+
151
382
  def _base_open_flags() -> int:
152
383
  flags = os.O_RDONLY
153
384
  if hasattr(os, "O_CLOEXEC"):
@@ -407,6 +638,277 @@ def save_entries(path: Path, entries: list[dict]) -> None:
407
638
  pass
408
639
 
409
640
 
641
+ def _read_bytes_no_follow(path: Path, limit: int = MAX_STATE_BYTES) -> bytes:
642
+ fd = _open_regular_no_symlink(path)
643
+ chunks: list[bytes] = []
644
+ remaining = limit + 1
645
+ try:
646
+ while remaining > 0:
647
+ chunk = os.read(fd, min(64 * 1024, remaining))
648
+ if not chunk:
649
+ break
650
+ chunks.append(chunk)
651
+ remaining -= len(chunk)
652
+ finally:
653
+ os.close(fd)
654
+ data = b"".join(chunks)
655
+ if len(data) > limit:
656
+ raise InvalidStateError(errno.EFBIG, "oversized v2 nudge state")
657
+ return data
658
+
659
+
660
+ def _atomic_write_json(path: Path, value: object) -> None:
661
+ """Write private JSON durably using only directory-relative no-follow IO."""
662
+ encoded = json.dumps(
663
+ value,
664
+ ensure_ascii=True,
665
+ sort_keys=True,
666
+ separators=(",", ":"),
667
+ ).encode("utf-8")
668
+ if len(encoded) > MAX_STATE_BYTES:
669
+ raise InvalidStateError(errno.EFBIG, "v2 nudge state exceeds size bound")
670
+
671
+ parent_fd = -1
672
+ tmp_fd = -1
673
+ tmp_name = f".nudge-v2-{os.getpid()}-{uuid.uuid4().hex}.tmp"
674
+ try:
675
+ parent_fd = _ensure_directory_no_symlink(path.parent, create=True)
676
+ tmp_fd = os.open(
677
+ tmp_name,
678
+ os.O_CREAT | os.O_EXCL | os.O_WRONLY | _no_follow_flag(),
679
+ 0o600,
680
+ dir_fd=parent_fd,
681
+ )
682
+ if not stat.S_ISREG(os.fstat(tmp_fd).st_mode):
683
+ raise UnsafeStatePathError(errno.EINVAL, "temporary state is not regular")
684
+ if hasattr(os, "fchmod"):
685
+ os.fchmod(tmp_fd, 0o600)
686
+ view = memoryview(encoded)
687
+ written = 0
688
+ while written < len(view):
689
+ count = os.write(tmp_fd, view[written:])
690
+ if count <= 0:
691
+ raise OSError(errno.EIO, "short state write")
692
+ written += count
693
+ os.fsync(tmp_fd)
694
+ os.close(tmp_fd)
695
+ tmp_fd = -1
696
+
697
+ try:
698
+ existing_fd = os.open(
699
+ path.name,
700
+ _base_open_flags() | _no_follow_flag(),
701
+ dir_fd=parent_fd,
702
+ )
703
+ except FileNotFoundError:
704
+ existing_fd = -1
705
+ else:
706
+ try:
707
+ if not stat.S_ISREG(os.fstat(existing_fd).st_mode):
708
+ raise UnsafeStatePathError(errno.EINVAL, "state is not regular")
709
+ finally:
710
+ os.close(existing_fd)
711
+
712
+ _rename_state_entry(tmp_name, path.name, parent_fd)
713
+ tmp_name = ""
714
+ os.fsync(parent_fd)
715
+ finally:
716
+ if tmp_fd != -1:
717
+ try:
718
+ os.close(tmp_fd)
719
+ except OSError:
720
+ pass
721
+ if parent_fd != -1:
722
+ if tmp_name:
723
+ try:
724
+ os.unlink(tmp_name, dir_fd=parent_fd)
725
+ except OSError:
726
+ pass
727
+ try:
728
+ os.close(parent_fd)
729
+ except OSError:
730
+ pass
731
+
732
+
733
+ @contextmanager
734
+ def state_lock(
735
+ path: Path = STATE_LOCK_PATH,
736
+ *,
737
+ timeout: float = STATE_LOCK_TIMEOUT_SECONDS,
738
+ ):
739
+ """Acquire a bounded private sibling lock without following symlinks."""
740
+ parent_fd = _ensure_directory_no_symlink(path.parent, create=True)
741
+ lock_fd = -1
742
+ try:
743
+ lock_fd = os.open(
744
+ path.name,
745
+ os.O_CREAT | os.O_RDWR | _no_follow_flag(),
746
+ 0o600,
747
+ dir_fd=parent_fd,
748
+ )
749
+ if not stat.S_ISREG(os.fstat(lock_fd).st_mode):
750
+ raise UnsafeStatePathError(errno.EINVAL, "state lock is not regular")
751
+ if hasattr(os, "fchmod"):
752
+ os.fchmod(lock_fd, 0o600)
753
+ deadline = time.monotonic() + max(0.0, timeout)
754
+ while True:
755
+ try:
756
+ fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
757
+ break
758
+ except BlockingIOError:
759
+ if time.monotonic() >= deadline:
760
+ raise StateLockTimeoutError(
761
+ errno.ETIMEDOUT,
762
+ "v2 nudge state lock timed out",
763
+ )
764
+ time.sleep(STATE_LOCK_POLL_SECONDS)
765
+ yield
766
+ finally:
767
+ if lock_fd != -1:
768
+ try:
769
+ fcntl.flock(lock_fd, fcntl.LOCK_UN)
770
+ except OSError:
771
+ pass
772
+ try:
773
+ os.close(lock_fd)
774
+ except OSError:
775
+ pass
776
+ os.close(parent_fd)
777
+
778
+
779
+ def empty_state() -> dict:
780
+ return {
781
+ "version": STATE_VERSION,
782
+ "episodes": [],
783
+ "events": [],
784
+ "counters": {},
785
+ }
786
+
787
+
788
+ _DIGEST_RE = re.compile(r"^[0-9a-f]{64}$")
789
+
790
+
791
+ def _valid_timestamp(value: object) -> bool:
792
+ return (
793
+ not isinstance(value, bool)
794
+ and isinstance(value, (int, float))
795
+ and math.isfinite(float(value))
796
+ and float(value) >= 0.0
797
+ )
798
+
799
+
800
+ def _validate_episode(value: object) -> dict:
801
+ if not isinstance(value, dict):
802
+ raise InvalidStateError(errno.EINVAL, "invalid episode record")
803
+ required = {
804
+ "key",
805
+ "session",
806
+ "protocol",
807
+ "command",
808
+ "state",
809
+ "count",
810
+ "updated_at",
811
+ }
812
+ if set(value) != required:
813
+ raise InvalidStateError(errno.EINVAL, "invalid episode fields")
814
+ if not all(
815
+ isinstance(value[name], str) and _DIGEST_RE.fullmatch(value[name])
816
+ for name in ("key", "session", "command")
817
+ ):
818
+ raise InvalidStateError(errno.EINVAL, "invalid episode digest")
819
+ if not isinstance(value["protocol"], str) or value["protocol"] not in PROTOCOLS:
820
+ raise InvalidStateError(errno.EINVAL, "invalid episode protocol")
821
+ if not isinstance(value["state"], str) or value["state"] not in {"tracking", "emitted"}:
822
+ raise InvalidStateError(errno.EINVAL, "invalid episode state")
823
+ count = value["count"]
824
+ if isinstance(count, bool) or not isinstance(count, int) or not 1 <= count <= MAX_COUNTER:
825
+ raise InvalidStateError(errno.EINVAL, "invalid episode count")
826
+ if value["state"] == "tracking" and count != 1:
827
+ raise InvalidStateError(errno.EINVAL, "invalid tracking count")
828
+ if value["state"] == "emitted" and count < 2:
829
+ raise InvalidStateError(errno.EINVAL, "invalid emitted count")
830
+ if not _valid_timestamp(value["updated_at"]):
831
+ raise InvalidStateError(errno.EINVAL, "invalid episode timestamp")
832
+ return dict(value)
833
+
834
+
835
+ def _validate_event(value: object) -> dict:
836
+ if not isinstance(value, dict):
837
+ raise InvalidStateError(errno.EINVAL, "invalid event record")
838
+ required = {"id", "episode", "outcome", "updated_at"}
839
+ if set(value) != required:
840
+ raise InvalidStateError(errno.EINVAL, "invalid event fields")
841
+ if not (
842
+ isinstance(value["id"], str)
843
+ and _DIGEST_RE.fullmatch(value["id"])
844
+ and isinstance(value["episode"], str)
845
+ and _DIGEST_RE.fullmatch(value["episode"])
846
+ ):
847
+ raise InvalidStateError(errno.EINVAL, "invalid event digest")
848
+ if value["outcome"] not in {"failure", "success"}:
849
+ raise InvalidStateError(errno.EINVAL, "invalid event outcome")
850
+ if not _valid_timestamp(value["updated_at"]):
851
+ raise InvalidStateError(errno.EINVAL, "invalid event timestamp")
852
+ return dict(value)
853
+
854
+
855
+ def validate_state(value: object) -> dict:
856
+ if not isinstance(value, dict) or set(value) != {
857
+ "version",
858
+ "episodes",
859
+ "events",
860
+ "counters",
861
+ }:
862
+ raise InvalidStateError(errno.EINVAL, "invalid v2 nudge state")
863
+ if value["version"] != STATE_VERSION:
864
+ raise InvalidStateError(errno.EINVAL, "unsupported v2 nudge state version")
865
+ if not isinstance(value["episodes"], list) or not isinstance(value["events"], list):
866
+ raise InvalidStateError(errno.EINVAL, "invalid v2 nudge collections")
867
+ if not isinstance(value["counters"], dict):
868
+ raise InvalidStateError(errno.EINVAL, "invalid v2 nudge counters")
869
+
870
+ episodes = [_validate_episode(item) for item in value["episodes"]]
871
+ events = [_validate_event(item) for item in value["events"]]
872
+ if len({item["key"] for item in episodes}) != len(episodes):
873
+ raise InvalidStateError(errno.EINVAL, "duplicate episode key")
874
+ if len({item["id"] for item in events}) != len(events):
875
+ raise InvalidStateError(errno.EINVAL, "duplicate event id")
876
+
877
+ counters: dict[str, int] = {}
878
+ for name, count in value["counters"].items():
879
+ if (
880
+ name not in COUNTER_NAMES
881
+ or isinstance(count, bool)
882
+ or not isinstance(count, int)
883
+ or not 0 <= count <= MAX_COUNTER
884
+ ):
885
+ raise InvalidStateError(errno.EINVAL, "invalid v2 nudge counter")
886
+ if count:
887
+ counters[name] = count
888
+ return {
889
+ "version": STATE_VERSION,
890
+ "episodes": episodes,
891
+ "events": events,
892
+ "counters": counters,
893
+ }
894
+
895
+
896
+ def load_state(path: Path = STATE_PATH) -> dict:
897
+ try:
898
+ raw = _read_bytes_no_follow(path)
899
+ except FileNotFoundError:
900
+ return empty_state()
901
+ try:
902
+ value = json.loads(raw.decode("utf-8"))
903
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
904
+ raise InvalidStateError(errno.EINVAL, "invalid v2 nudge JSON") from exc
905
+ return validate_state(value)
906
+
907
+
908
+ def save_state(state: dict, path: Path = STATE_PATH) -> None:
909
+ _atomic_write_json(path, validate_state(state))
910
+
911
+
410
912
  def safe_session_label(session_id: str | None) -> str | None:
411
913
  """session_id 를 파일명 안전 digest 로 변환. 없으면 None — 호출자가 hook 을 noop 한다."""
412
914
  if not session_id or not isinstance(session_id, str):
@@ -459,6 +961,250 @@ def read_bounded_stdin_text(limit: int = MAX_HOOK_STDIN_BYTES) -> tuple[str | No
459
961
  return data.decode("utf-8", errors="replace"), False
460
962
 
461
963
 
964
+ _MISSING = object()
965
+ _AMBIGUOUS = object()
966
+
967
+
968
+ def _alias(value: dict, snake: str, camel: str) -> object:
969
+ snake_value = value.get(snake, _MISSING)
970
+ camel_value = value.get(camel, _MISSING)
971
+ if snake_value is not _MISSING and camel_value is not _MISSING:
972
+ return snake_value if snake_value == camel_value else _AMBIGUOUS
973
+ return snake_value if snake_value is not _MISSING else camel_value
974
+
975
+
976
+ def classify_terminal_event(payload: dict) -> tuple[TerminalEvent | None, str | None]:
977
+ """Return one valid terminal event or a bounded no-transition reason."""
978
+ tool_name = _alias(payload, "tool_name", "toolName")
979
+ if tool_name is _AMBIGUOUS:
980
+ return None, "malformed_event"
981
+ if tool_name != "Bash":
982
+ return None, None
983
+
984
+ hook_event_name = _alias(payload, "hook_event_name", "hookEventName")
985
+ if (
986
+ not isinstance(hook_event_name, str)
987
+ or hook_event_name not in {"PostToolUse", "PostToolUseFailure"}
988
+ ):
989
+ return None, "malformed_event"
990
+
991
+ tool_input = _alias(payload, "tool_input", "toolInput")
992
+ if not isinstance(tool_input, dict):
993
+ return None, "malformed_event"
994
+ command = tool_input.get("command")
995
+ if not isinstance(command, str) or not command.strip():
996
+ return None, "malformed_event"
997
+
998
+ session_id = _alias(payload, "session_id", "sessionId")
999
+ if session_id is _AMBIGUOUS:
1000
+ return None, "malformed_event"
1001
+ if not isinstance(session_id, str) or not session_id:
1002
+ return None, "missing_session"
1003
+ tool_use_id = _alias(payload, "tool_use_id", "toolUseId")
1004
+ if tool_use_id is _AMBIGUOUS:
1005
+ return None, "malformed_event"
1006
+ if not isinstance(tool_use_id, str) or not tool_use_id:
1007
+ return None, "missing_tool_id"
1008
+
1009
+ outcome: str
1010
+ if hook_event_name == "PostToolUse":
1011
+ tool_response = _alias(payload, "tool_response", "toolResponse")
1012
+ if not isinstance(tool_response, dict):
1013
+ return None, "malformed_event"
1014
+ interrupted = tool_response.get("interrupted", False)
1015
+ if not isinstance(interrupted, bool):
1016
+ return None, "malformed_event"
1017
+ if interrupted:
1018
+ return None, "interrupted"
1019
+ alternate_exit_fields = {"exitCode", "returncode"}.intersection(tool_response)
1020
+ if alternate_exit_fields:
1021
+ return None, "ambiguous_exit"
1022
+ if "exit_code" not in tool_response:
1023
+ return None, "missing_exit"
1024
+ exit_code = tool_response["exit_code"]
1025
+ if isinstance(exit_code, bool) or not isinstance(exit_code, int):
1026
+ return None, "ambiguous_exit"
1027
+ outcome = "success" if exit_code == 0 else "failure"
1028
+ else:
1029
+ error = payload.get("error")
1030
+ is_interrupt = _alias(payload, "is_interrupt", "isInterrupt")
1031
+ if is_interrupt is _AMBIGUOUS:
1032
+ return None, "malformed_event"
1033
+ if is_interrupt is _MISSING:
1034
+ is_interrupt = False
1035
+ if not isinstance(error, str) or not error:
1036
+ return None, "malformed_event"
1037
+ if not isinstance(is_interrupt, bool):
1038
+ return None, "malformed_event"
1039
+ lowered_error = error.casefold()
1040
+ if is_interrupt or any(
1041
+ marker in lowered_error
1042
+ for marker in ("interrupt", "cancelled", "canceled")
1043
+ ):
1044
+ return None, "interrupted"
1045
+ outcome = "failure"
1046
+
1047
+ return TerminalEvent(
1048
+ hook_event_name=hook_event_name,
1049
+ outcome=outcome,
1050
+ session_digest=sha256_text(session_id),
1051
+ tool_id_digest=sha256_text(tool_use_id),
1052
+ command_identity=command_identity(command),
1053
+ ), None
1054
+
1055
+
1056
+ def _increment_counter(state: dict, name: str, amount: int = 1) -> None:
1057
+ if name not in COUNTER_NAMES or amount <= 0:
1058
+ return
1059
+ counters = state["counters"]
1060
+ counters[name] = min(MAX_COUNTER, int(counters.get(name, 0)) + amount)
1061
+
1062
+
1063
+ def _prune_expired(state: dict, now: float) -> None:
1064
+ episodes = [
1065
+ item
1066
+ for item in state["episodes"]
1067
+ if now - float(item["updated_at"]) <= STATE_TTL_SECONDS
1068
+ ]
1069
+ events = [
1070
+ item
1071
+ for item in state["events"]
1072
+ if now - float(item["updated_at"]) <= STATE_TTL_SECONDS
1073
+ ]
1074
+ _increment_counter(state, "episode_expired", len(state["episodes"]) - len(episodes))
1075
+ _increment_counter(state, "event_expired", len(state["events"]) - len(events))
1076
+ state["episodes"] = episodes
1077
+ state["events"] = events
1078
+
1079
+
1080
+ def _enforce_lru(state: dict) -> None:
1081
+ if len(state["episodes"]) > MAX_EPISODES:
1082
+ ordered = sorted(
1083
+ state["episodes"],
1084
+ key=lambda item: (float(item["updated_at"]), item["key"]),
1085
+ )
1086
+ evict = len(ordered) - MAX_EPISODES
1087
+ evicted_keys = {item["key"] for item in ordered[:evict]}
1088
+ state["episodes"] = [
1089
+ item for item in state["episodes"] if item["key"] not in evicted_keys
1090
+ ]
1091
+ _increment_counter(state, "episode_evicted", evict)
1092
+ if len(state["events"]) > MAX_EVENT_IDS:
1093
+ ordered = sorted(
1094
+ state["events"],
1095
+ key=lambda item: (float(item["updated_at"]), item["id"]),
1096
+ )
1097
+ evict = len(ordered) - MAX_EVENT_IDS
1098
+ evicted_ids = {item["id"] for item in ordered[:evict]}
1099
+ state["events"] = [
1100
+ item for item in state["events"] if item["id"] not in evicted_ids
1101
+ ]
1102
+ _increment_counter(state, "event_evicted", evict)
1103
+
1104
+
1105
+ def _nudge_response(hook_event_name: str) -> dict:
1106
+ return {
1107
+ "hookSpecificOutput": {
1108
+ "hookEventName": hook_event_name,
1109
+ "additionalContext": NUDGE_TEXT,
1110
+ }
1111
+ }
1112
+
1113
+
1114
+ def apply_payload(
1115
+ state: dict,
1116
+ payload: dict,
1117
+ *,
1118
+ now: float | None = None,
1119
+ ) -> tuple[dict, dict]:
1120
+ """Pure v2 FSM transition used by the locked runtime and protocol tests."""
1121
+ state = validate_state(state)
1122
+ timestamp = time.time() if now is None else float(now)
1123
+ if not _valid_timestamp(timestamp):
1124
+ raise ValueError("now must be a finite non-negative timestamp")
1125
+ _prune_expired(state, timestamp)
1126
+ _enforce_lru(state)
1127
+
1128
+ event, reason = classify_terminal_event(payload)
1129
+ if event is None:
1130
+ if reason is not None:
1131
+ _increment_counter(state, reason)
1132
+ return state, {}
1133
+
1134
+ existing_event = next(
1135
+ (item for item in state["events"] if item["id"] == event.tool_id_digest),
1136
+ None,
1137
+ )
1138
+ if existing_event is not None:
1139
+ if (
1140
+ existing_event["episode"] == event.episode_key
1141
+ and existing_event["outcome"] == event.outcome
1142
+ ):
1143
+ _increment_counter(state, "dedupe")
1144
+ else:
1145
+ _increment_counter(state, "conflict")
1146
+ return state, {}
1147
+
1148
+ state["events"].append({
1149
+ "id": event.tool_id_digest,
1150
+ "episode": event.episode_key,
1151
+ "outcome": event.outcome,
1152
+ "updated_at": timestamp,
1153
+ })
1154
+ episode = next(
1155
+ (item for item in state["episodes"] if item["key"] == event.episode_key),
1156
+ None,
1157
+ )
1158
+
1159
+ response: dict = {}
1160
+ if event.outcome == "success":
1161
+ if episode is not None:
1162
+ state["episodes"].remove(episode)
1163
+ _increment_counter(state, "success_reset")
1164
+ elif episode is None:
1165
+ state["episodes"].append({
1166
+ "key": event.episode_key,
1167
+ "session": event.session_digest,
1168
+ "protocol": event.command_identity.protocol,
1169
+ "command": event.command_identity.digest,
1170
+ "state": "tracking",
1171
+ "count": 1,
1172
+ "updated_at": timestamp,
1173
+ })
1174
+ _increment_counter(state, "tracking_started")
1175
+ elif episode["state"] == "tracking":
1176
+ episode["state"] = "emitted"
1177
+ episode["count"] = 2
1178
+ episode["updated_at"] = timestamp
1179
+ _increment_counter(state, "nudge_emitted")
1180
+ response = _nudge_response(event.hook_event_name)
1181
+ else:
1182
+ episode["count"] = min(MAX_COUNTER, int(episode["count"]) + 1)
1183
+ episode["updated_at"] = timestamp
1184
+ _increment_counter(state, "failure_after_emit")
1185
+
1186
+ _enforce_lru(state)
1187
+ return validate_state(state), response
1188
+
1189
+
1190
+ def update_state_transaction(
1191
+ payload: dict,
1192
+ *,
1193
+ now: float | None = None,
1194
+ state_path: Path = STATE_PATH,
1195
+ lock_path: Path = STATE_LOCK_PATH,
1196
+ ) -> dict:
1197
+ """Serialize one event and return output only after durable persistence."""
1198
+ event, _reason = classify_terminal_event(payload)
1199
+ if event is None and _reason is None:
1200
+ return {}
1201
+ with state_lock(lock_path):
1202
+ state = load_state(state_path)
1203
+ state, response = apply_payload(state, payload, now=now)
1204
+ save_state(state, state_path)
1205
+ return response
1206
+
1207
+
462
1208
  def update_entries(entries: list[dict], fp: str, success: bool) -> list[dict]:
463
1209
  """성공한 fingerprint 는 카운트 리셋. 실패는 append.
464
1210
 
@@ -504,68 +1250,16 @@ def main() -> int:
504
1250
  if not isinstance(payload, dict):
505
1251
  print("{}")
506
1252
  return 0
507
-
508
- tool_name = payload.get("tool_name") or payload.get("toolName")
509
- if tool_name != "Bash":
510
- print("{}")
511
- return 0
512
-
513
- tool_input = payload.get("tool_input") or payload.get("toolInput") or {}
514
- tool_response = payload.get("tool_response") or payload.get("toolResponse") or {}
515
- if not isinstance(tool_input, dict) or not isinstance(tool_response, dict):
516
- print("{}")
517
- return 0
518
-
519
- command = tool_input.get("command")
520
- if not isinstance(command, str) or not command.strip():
521
- print("{}")
522
- return 0
523
-
524
- exit_code = extract_exit_code(tool_response)
525
- if exit_code is None:
526
- # exit_code 미확정 — 실패 여부를 모르므로 회귀 위험 방지 차원에서 noop.
527
- print("{}")
528
- return 0
529
-
530
- session = safe_session_label(payload.get("session_id") or payload.get("sessionId"))
531
- if session is None:
532
- # session_id 가 없으면 cross-session 오염 위험으로 그냥 noop. 상태 파일도 만들지 않는다.
533
- print("{}")
534
- return 0
535
-
536
- fp = fingerprint(normalize_command(command))
537
- state_path = STATE_DIR / STATE_FILE_TEMPLATE.format(session=session)
538
-
539
1253
  try:
540
- entries = load_entries(state_path)
1254
+ response = update_state_transaction(payload)
541
1255
  except OSError as exc:
542
- # state 읽기 실패해도 실행을 막지 않는다. 진단 신호만 stderr 남긴 뒤 새 streak 으로 시작한다.
543
- sys.stderr.write(f"context-guard-failed-nudge: state read skipped: {diagnostic_text(exc)}\n")
544
- entries = []
545
- success = exit_code == 0
546
- entries = update_entries(entries, fp, success)
547
- try:
548
- save_entries(state_path, entries)
549
- except OSError as exc:
550
- # state 저장 실패해도 실행을 막지 않는다. 진단 신호만 stderr 에 남긴다.
551
- sys.stderr.write(f"context-guard-failed-nudge: state write skipped: {diagnostic_text(exc)}\n")
552
-
553
- if success:
554
- # 성공이면 nudge 는 절대 발화하지 않는다.
555
- print("{}")
556
- return 0
557
-
558
- consecutive = count_consecutive_failures(entries, fp)
559
- if consecutive < MIN_CONSECUTIVE:
560
- print("{}")
561
- return 0
562
-
563
- response = {
564
- "hookSpecificOutput": {
565
- "hookEventName": "PostToolUse",
566
- "additionalContext": NUDGE_TEXT + (STRATEGY_SWITCH_TEXT if consecutive >= STRATEGY_SWITCH_MIN_CONSECUTIVE else ""),
567
- }
568
- }
1256
+ # Never emit from an uncommitted transition: a later retry must not
1257
+ # produce repeated text after read/lock/durability failure.
1258
+ sys.stderr.write(
1259
+ "context-guard-failed-nudge: state update skipped: "
1260
+ f"{diagnostic_text(exc)}\n"
1261
+ )
1262
+ response = {}
569
1263
  print(json.dumps(response, ensure_ascii=False))
570
1264
  return 0
571
1265