@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.
- package/CHANGELOG.md +29 -0
- package/README.ko.md +92 -37
- package/README.md +111 -37
- 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 +27 -12
- 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 +2211 -121
- 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 -2339
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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
|