@ictechgy/context-guard 0.4.8 → 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.
Files changed (54) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.ko.md +92 -37
  3. package/README.md +111 -37
  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.tasks.example.json +182 -0
  7. package/docs/benchmark-fixtures/token-savings-12task.variants.example.json +10 -0
  8. package/docs/distribution.md +10 -7
  9. package/docs/experimental-benchmark-fixtures.md +8 -1
  10. package/package.json +3 -6
  11. package/packaging/homebrew/context-guard.rb.template +1 -1
  12. package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
  13. package/plugins/context-guard/README.ko.md +9 -6
  14. package/plugins/context-guard/README.md +27 -12
  15. package/plugins/context-guard/bin/context-guard +113 -26
  16. package/plugins/context-guard/bin/context-guard-artifact +542 -46
  17. package/plugins/context-guard/bin/context-guard-cache-score +380 -0
  18. package/plugins/context-guard/bin/context-guard-compress +146 -1
  19. package/plugins/context-guard/bin/context-guard-cost +783 -4
  20. package/plugins/context-guard/bin/context-guard-experiments +2211 -121
  21. package/plugins/context-guard/bin/context-guard-failed-nudge +3 -0
  22. package/plugins/context-guard/bin/context-guard-filter +163 -7
  23. package/plugins/context-guard/bin/context-guard-guard-read +3 -0
  24. package/plugins/context-guard/bin/context-guard-pack +602 -43
  25. package/plugins/context-guard/bin/context-guard-rewrite-bash +3 -0
  26. package/plugins/context-guard/bin/context-guard-setup +165 -31
  27. package/plugins/context-guard/bin/context-guard-statusline +490 -283
  28. package/plugins/context-guard/bin/context-guard-statusline-merged +5 -0
  29. package/plugins/context-guard/bin/context-guard-tool-prune +241 -1
  30. package/plugins/context-guard/lib/context_guard_commands.py +206 -0
  31. package/plugins/context-guard/skills/setup/SKILL.md +1 -0
  32. package/context-guard-kit/README.md +0 -91
  33. package/context-guard-kit/benchmark_runner.py +0 -2401
  34. package/context-guard-kit/claude_transcript_cost_audit.py +0 -2346
  35. package/context-guard-kit/context_compress.py +0 -695
  36. package/context-guard-kit/context_escrow.py +0 -935
  37. package/context-guard-kit/context_filter.py +0 -637
  38. package/context-guard-kit/context_guard_cli.py +0 -325
  39. package/context-guard-kit/context_guard_diet.py +0 -1711
  40. package/context-guard-kit/context_pack.py +0 -2713
  41. package/context-guard-kit/cost_guard.py +0 -2349
  42. package/context-guard-kit/experimental_registry.py +0 -2339
  43. package/context-guard-kit/failed_attempt_nudge.py +0 -567
  44. package/context-guard-kit/guard_large_read.py +0 -690
  45. package/context-guard-kit/hook_secret_patterns.py +0 -43
  46. package/context-guard-kit/read_symbol.py +0 -483
  47. package/context-guard-kit/rewrite_bash_for_token_budget.py +0 -501
  48. package/context-guard-kit/sanitize_output.py +0 -725
  49. package/context-guard-kit/settings.example.json +0 -67
  50. package/context-guard-kit/setup_wizard.py +0 -2515
  51. package/context-guard-kit/statusline.sh +0 -362
  52. package/context-guard-kit/statusline_merged.sh +0 -157
  53. package/context-guard-kit/tool_schema_pruner.py +0 -837
  54. package/context-guard-kit/trim_command_output.py +0 -1449
@@ -484,6 +484,9 @@ def count_consecutive_failures(entries: list[dict], fp: str) -> int:
484
484
 
485
485
 
486
486
  def main() -> int:
487
+ if any(arg in {"-h", "--help"} for arg in sys.argv[1:]):
488
+ print("ContextGuard helper: context-guard-failed-nudge")
489
+ return 0
487
490
  raw_payload, oversized = read_bounded_stdin_text()
488
491
  if oversized:
489
492
  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