@ictechgy/context-guard 0.4.9 → 0.4.11

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 (64) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.ko.md +59 -31
  3. package/README.md +85 -36
  4. package/docs/benchmark-fixtures/token-savings-12task-baseline.prompt.example.md +7 -0
  5. package/docs/benchmark-fixtures/token-savings-12task-contextguard.prompt.example.md +7 -0
  6. package/docs/benchmark-fixtures/token-savings-12task.evidence.example.jsonl +24 -0
  7. package/docs/benchmark-fixtures/token-savings-12task.tasks.example.json +182 -0
  8. package/docs/benchmark-fixtures/token-savings-12task.variants.example.json +10 -0
  9. package/docs/benchmark-workflow-examples.md +3 -0
  10. package/docs/benchmark-workflows/context-pack-byte-proxy.example.json +278 -137
  11. package/docs/benchmark-workflows/measured-token-workflow.example.json +279 -138
  12. package/docs/benchmark-workflows/provider-cache-telemetry.example.json +279 -138
  13. package/docs/distribution.md +10 -7
  14. package/docs/experimental-benchmark-fixtures.md +30 -6
  15. package/package.json +4 -6
  16. package/packaging/homebrew/context-guard.rb.template +1 -1
  17. package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
  18. package/plugins/context-guard/README.ko.md +20 -14
  19. package/plugins/context-guard/README.md +26 -17
  20. package/plugins/context-guard/bin/context-guard +147 -25
  21. package/plugins/context-guard/bin/context-guard-artifact +884 -79
  22. package/plugins/context-guard/bin/context-guard-audit +33 -2
  23. package/plugins/context-guard/bin/context-guard-bench +1542 -31
  24. package/plugins/context-guard/bin/context-guard-cache-score +665 -0
  25. package/plugins/context-guard/bin/context-guard-compress +146 -1
  26. package/plugins/context-guard/bin/context-guard-cost +790 -6
  27. package/plugins/context-guard/bin/context-guard-experiments +463 -26
  28. package/plugins/context-guard/bin/context-guard-failed-nudge +9 -2
  29. package/plugins/context-guard/bin/context-guard-filter +163 -7
  30. package/plugins/context-guard/bin/context-guard-guard-read +3 -0
  31. package/plugins/context-guard/bin/context-guard-pack +892 -49
  32. package/plugins/context-guard/bin/context-guard-rewrite-bash +3 -0
  33. package/plugins/context-guard/bin/context-guard-sanitize-output +76 -12
  34. package/plugins/context-guard/bin/context-guard-setup +165 -31
  35. package/plugins/context-guard/bin/context-guard-statusline +490 -283
  36. package/plugins/context-guard/bin/context-guard-statusline-merged +5 -0
  37. package/plugins/context-guard/bin/context-guard-tool-prune +480 -53
  38. package/plugins/context-guard/bin/context-guard-trim-output +288 -41
  39. package/plugins/context-guard/brief/README.md +5 -5
  40. package/plugins/context-guard/lib/context_guard_commands.py +230 -0
  41. package/plugins/context-guard/skills/setup/SKILL.md +1 -0
  42. package/context-guard-kit/README.md +0 -91
  43. package/context-guard-kit/benchmark_runner.py +0 -2401
  44. package/context-guard-kit/claude_transcript_cost_audit.py +0 -2346
  45. package/context-guard-kit/context_compress.py +0 -695
  46. package/context-guard-kit/context_escrow.py +0 -935
  47. package/context-guard-kit/context_filter.py +0 -637
  48. package/context-guard-kit/context_guard_cli.py +0 -325
  49. package/context-guard-kit/context_guard_diet.py +0 -1711
  50. package/context-guard-kit/context_pack.py +0 -2713
  51. package/context-guard-kit/cost_guard.py +0 -2349
  52. package/context-guard-kit/experimental_registry.py +0 -4348
  53. package/context-guard-kit/failed_attempt_nudge.py +0 -567
  54. package/context-guard-kit/guard_large_read.py +0 -690
  55. package/context-guard-kit/hook_secret_patterns.py +0 -43
  56. package/context-guard-kit/read_symbol.py +0 -483
  57. package/context-guard-kit/rewrite_bash_for_token_budget.py +0 -501
  58. package/context-guard-kit/sanitize_output.py +0 -725
  59. package/context-guard-kit/settings.example.json +0 -67
  60. package/context-guard-kit/setup_wizard.py +0 -2515
  61. package/context-guard-kit/statusline.sh +0 -362
  62. package/context-guard-kit/statusline_merged.sh +0 -157
  63. package/context-guard-kit/tool_schema_pruner.py +0 -837
  64. package/context-guard-kit/trim_command_output.py +0 -1449
@@ -92,12 +92,16 @@ NUDGE_TEXT = (
92
92
  "prompt cache 도 매 retry 마다 재워밍됨을 의미합니다. "
93
93
  "재시도 전에 사용자에게 `/clear` 또는 `/compact focus on …` 으로 세션을 정리한 뒤 "
94
94
  "재현 명령·기대 결과·금지 사항을 더 좁혀 다시 prompt 하도록 안내하거나, "
95
- "근본적으로 다른 방향(다른 모듈 / 검증 명령 / 더 작은 재현)을 제안하세요."
95
+ "근본적으로 다른 방향(다른 모듈 / 검증 명령 / 더 작은 재현)을 제안하세요. "
96
+ "직전 출력에 artifact_receipt 또는 contextguard-artifact:<id> 핸들이 있으면, 전체 로그를 다시 붙여넣거나 "
97
+ "동일한 broad 명령을 재실행하기 전에 context-guard-artifact receipt/get/search 로 필요한 줄·패턴만 "
98
+ "정확히 rehydrate 하도록 우선 제안하세요."
96
99
  )
97
100
  STRATEGY_SWITCH_TEXT = (
98
101
  " Strategy-switch signal: the same failure direction has now repeated at least three times. "
99
102
  "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."
103
+ "or smaller reproducer, rehydrate exact artifact receipt slices when available, "
104
+ "and only rerun after changing code, inputs, or diagnostic scope."
101
105
  )
102
106
 
103
107
 
@@ -484,6 +488,9 @@ def count_consecutive_failures(entries: list[dict], fp: str) -> int:
484
488
 
485
489
 
486
490
  def main() -> int:
491
+ if any(arg in {"-h", "--help"} for arg in sys.argv[1:]):
492
+ print("ContextGuard helper: context-guard-failed-nudge")
493
+ return 0
487
494
  raw_payload, oversized = read_bounded_stdin_text()
488
495
  if oversized:
489
496
  sys.stderr.write("context-guard-failed-nudge: oversized hook JSON skipped\n")
@@ -16,6 +16,7 @@ from pathlib import Path
16
16
  import re
17
17
  import shlex
18
18
  import signal
19
+ import stat
19
20
  import subprocess
20
21
  import sys
21
22
  import threading
@@ -86,16 +87,171 @@ def compact(text: str, limit: int = 160) -> str:
86
87
  return text[: max(0, limit - 20)] + f"…[trimmed:{len(text)}]"
87
88
 
88
89
 
89
- def read_json_limited(path: Path) -> tuple[Any | None, list[str]]:
90
+ ALLOWED_FIRST_ABSOLUTE_SYMLINKS = {
91
+ "tmp": Path("/private/tmp"),
92
+ "var": Path("/private/var"),
93
+ }
94
+ NO_FOLLOW_SUPPORTED = hasattr(os, "O_NOFOLLOW")
95
+ DIR_FD_OPEN_SUPPORTED = bool(os.supports_dir_fd and os.open in os.supports_dir_fd)
96
+ DIR_FD_STAT_SUPPORTED = bool(os.supports_dir_fd and os.stat in os.supports_dir_fd)
97
+ DIR_FD_MKDIR_SUPPORTED = bool(os.supports_dir_fd and os.mkdir in os.supports_dir_fd)
98
+ NONBLOCK_SUPPORTED = hasattr(os, "O_NONBLOCK")
99
+
100
+
101
+ def os_error_detail(exc: OSError) -> str:
102
+ detail = exc.strerror or str(exc) or exc.__class__.__name__
103
+ if exc.errno is not None:
104
+ return f"{detail} (errno {exc.errno})"
105
+ return detail
106
+
107
+
108
+ def normalized_link_target(parent: Path, raw_target: str) -> Path:
109
+ target = Path(raw_target)
110
+ if not target.is_absolute():
111
+ target = parent / target
112
+ return Path(os.path.normpath(str(target)))
113
+
114
+
115
+ def normalize_allowed_first_absolute_symlink(path: Path) -> Path:
116
+ if not path.is_absolute() or len(path.parts) < 2:
117
+ return path
118
+ first = path.parts[1]
119
+ expected = ALLOWED_FIRST_ABSOLUTE_SYMLINKS.get(first)
120
+ if expected is None:
121
+ return path
122
+ link = Path(path.anchor) / first
123
+ try:
124
+ if not stat.S_ISLNK(os.lstat(link).st_mode):
125
+ return path
126
+ if normalized_link_target(Path(path.anchor), os.readlink(link)) != expected:
127
+ return path
128
+ except OSError:
129
+ return path
130
+ return expected.joinpath(*path.parts[2:])
131
+
132
+
133
+ def normalize_config_path(path: Path) -> Path:
134
+ path = path.expanduser()
135
+ if any(part == ".." for part in path.parts):
136
+ raise OSError("config path must not contain parent traversal")
137
+ if not path.is_absolute():
138
+ path = Path.cwd() / path
139
+ return normalize_allowed_first_absolute_symlink(Path(os.path.normpath(str(path))))
140
+
141
+
142
+ def no_follow_dir_flags() -> int:
143
+ if not NO_FOLLOW_SUPPORTED:
144
+ raise OSError("O_NOFOLLOW is required for safe config reads")
145
+ flags = os.O_RDONLY | os.O_NOFOLLOW
146
+ if hasattr(os, "O_CLOEXEC"):
147
+ flags |= os.O_CLOEXEC
148
+ if hasattr(os, "O_DIRECTORY"):
149
+ flags |= os.O_DIRECTORY
150
+ return flags
151
+
152
+
153
+ def no_follow_file_flags() -> int:
154
+ if not NO_FOLLOW_SUPPORTED:
155
+ raise OSError("O_NOFOLLOW is required for safe config reads")
156
+ if not NONBLOCK_SUPPORTED:
157
+ raise OSError("O_NONBLOCK is required for safe config reads")
158
+ flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK
159
+ if hasattr(os, "O_CLOEXEC"):
160
+ flags |= os.O_CLOEXEC
161
+ if hasattr(os, "O_NOCTTY"):
162
+ flags |= os.O_NOCTTY
163
+ return flags
164
+
165
+
166
+ def open_config_parent_no_follow(path: Path) -> int:
167
+ if not DIR_FD_OPEN_SUPPORTED:
168
+ raise OSError("dir_fd open support is required for safe config reads")
169
+ flags = no_follow_dir_flags()
170
+ if path.is_absolute():
171
+ anchor = path.anchor or os.sep
172
+ current_fd = os.open(anchor, os.O_RDONLY | (os.O_CLOEXEC if hasattr(os, "O_CLOEXEC") else 0))
173
+ parts = path.parts[1:-1]
174
+ else:
175
+ current_fd = os.open(".", flags)
176
+ parts = path.parts[:-1]
177
+ try:
178
+ for part in parts:
179
+ if part in {"", "."}:
180
+ continue
181
+ if part == "..":
182
+ raise OSError("config path must not contain parent traversal")
183
+ next_fd = os.open(part, flags, dir_fd=current_fd)
184
+ try:
185
+ if not stat.S_ISDIR(os.fstat(next_fd).st_mode):
186
+ raise OSError("config path must not traverse non-directory components")
187
+ except Exception:
188
+ os.close(next_fd)
189
+ raise
190
+ os.close(current_fd)
191
+ current_fd = next_fd
192
+ owned_fd = current_fd
193
+ current_fd = -1
194
+ return owned_fd
195
+ finally:
196
+ if current_fd >= 0:
197
+ os.close(current_fd)
198
+
199
+
200
+ def read_config_text_no_follow(path: Path, max_bytes: int) -> tuple[str | None, list[str]]:
201
+ parent_fd = -1
202
+ fd = -1
90
203
  try:
91
- size = path.stat().st_size
92
- if size > MAX_CONFIG_BYTES:
93
- return None, [f"config file too large: {size}>{MAX_CONFIG_BYTES} bytes"]
94
- raw = path.read_text(encoding="utf-8")
204
+ path = normalize_config_path(path)
205
+ parent_fd = open_config_parent_no_follow(path)
206
+ leaf = path.name
207
+ if leaf in {"", ".", ".."}:
208
+ return None, ["config path must name a regular file"]
209
+ if not DIR_FD_STAT_SUPPORTED:
210
+ raise OSError("dir_fd stat support is required for safe config reads")
211
+ try:
212
+ st = os.stat(leaf, dir_fd=parent_fd, follow_symlinks=False)
213
+ except FileNotFoundError:
214
+ return None, ["could not read config: missing file"]
215
+ if not stat.S_ISREG(st.st_mode):
216
+ return None, ["config must be a regular file"]
217
+ if st.st_size > max_bytes:
218
+ return None, [f"config file too large: {st.st_size}>{max_bytes} bytes"]
219
+ fd = os.open(leaf, no_follow_file_flags(), dir_fd=parent_fd)
220
+ fst = os.fstat(fd)
221
+ if not stat.S_ISREG(fst.st_mode):
222
+ return None, ["config must be a regular file"]
223
+ if fst.st_size > max_bytes:
224
+ return None, [f"config file too large: {fst.st_size}>{max_bytes} bytes"]
225
+ chunks: list[bytes] = []
226
+ remaining = max_bytes + 1
227
+ while remaining > 0:
228
+ chunk = os.read(fd, min(64 * 1024, remaining))
229
+ if not chunk:
230
+ break
231
+ chunks.append(chunk)
232
+ remaining -= len(chunk)
233
+ raw = b"".join(chunks)
234
+ if len(raw) > max_bytes:
235
+ return None, [f"config file too large: >{max_bytes} bytes"]
236
+ try:
237
+ return raw.decode("utf-8"), []
238
+ except UnicodeDecodeError as exc:
239
+ return None, [f"could not decode config UTF-8: {exc.reason}"]
95
240
  except OSError as exc:
96
- return None, [f"could not read config: {exc.strerror or exc.__class__.__name__}"]
241
+ return None, [f"could not read config safely: {os_error_detail(exc)}"]
242
+ finally:
243
+ if fd >= 0:
244
+ os.close(fd)
245
+ if parent_fd >= 0:
246
+ os.close(parent_fd)
247
+
248
+
249
+ def read_json_limited(path: Path) -> tuple[Any | None, list[str]]:
250
+ raw, read_errors = read_config_text_no_follow(path, MAX_CONFIG_BYTES)
251
+ if read_errors:
252
+ return None, read_errors
97
253
  try:
98
- return json.loads(raw), []
254
+ return json.loads(raw if raw is not None else ""), []
99
255
  except json.JSONDecodeError as exc:
100
256
  return None, [f"invalid JSON at line {exc.lineno}: {exc.msg}"]
101
257
 
@@ -607,6 +607,9 @@ def deny_response(reason: str) -> dict[str, Any]:
607
607
 
608
608
 
609
609
  def main() -> int:
610
+ if any(arg in {"-h", "--help"} for arg in sys.argv[1:]):
611
+ print("ContextGuard helper: context-guard-guard-read")
612
+ return 0
610
613
  if truthy_disabled(env_value(GUARD_ENV, LEGACY_GUARD_ENV)):
611
614
  print("{}")
612
615
  return 0