@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,43 @@
1
+ #!/usr/bin/env python3
2
+ """Shared high-confidence secret patterns for hook-visible text.
3
+
4
+ These patterns are intentionally narrower than the full output sanitizer: hooks
5
+ use them on user-controlled path labels and short diagnostics where false
6
+ positives should redact the whole label rather than rewrite large command
7
+ output. Keep alternatives bounded or structurally linear; hooks run before any
8
+ downstream sanitizer can protect their own stderr/JSON output.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+
15
+ CONTROL_CHAR_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]")
16
+ SENSITIVE_HOOK_TEXT_RE = re.compile(
17
+ r"(?i)("
18
+ r"gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|"
19
+ r"glpat-[A-Za-z0-9_-]{12,}|(?:AKIA|ASIA)[0-9A-Z]{16}|"
20
+ r"(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}|"
21
+ r"sk-(?:ant|proj)-[A-Za-z0-9_-]{8,}|xox[abprs]-[A-Za-z0-9-]{8,}|"
22
+ r"npm_[A-Za-z0-9]{20,}|AIza[0-9A-Za-z_-]{20,}|"
23
+ r"SG\.[A-Za-z0-9_-]{16,256}\.[A-Za-z0-9_-]{16,512}|"
24
+ r"eyJ[A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]*){2}|"
25
+ r"\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{12,}|"
26
+ r"[a-z][a-z0-9+.-]{0,31}:/+(?:[^/\s:@]{0,256}:[^/\s@]{0,2048}|[^/\s@]{1,2048})@|"
27
+ r"(?<![A-Za-z0-9])(?:api[_-]?key|token|secret|password|client[_-]?secret)\s*(?:=|:|%3d)[^/\\\s]{4,})"
28
+ )
29
+
30
+
31
+ def hook_text_has_sensitive_evidence(value: str) -> bool:
32
+ """Return True when hook-visible text contains a high-confidence secret."""
33
+ return bool(SENSITIVE_HOOK_TEXT_RE.search(value))
34
+
35
+
36
+ def hook_label_has_sensitive_evidence(value: str) -> bool:
37
+ """Return True when a compact hook-visible label must be fully redacted."""
38
+ return bool(CONTROL_CHAR_RE.search(value) or hook_text_has_sensitive_evidence(value))
39
+
40
+
41
+ def redact_sensitive_hook_text(value: str, replacement: str = "[redacted]") -> str:
42
+ """Redact high-confidence secrets from hook-visible text."""
43
+ return SENSITIVE_HOOK_TEXT_RE.sub(replacement, value)
@@ -0,0 +1,483 @@
1
+ #!/usr/bin/env python3
2
+ """Print a symbol-sized slice of a source file instead of the whole file.
3
+
4
+ This is a deliberately small, dependency-free helper for Claude Code sessions:
5
+ use it after grep/ripgrep identifies a symbol and before asking Claude to read a
6
+ large source file. It is heuristic, not a full language server.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import ast
12
+ import errno
13
+ import hashlib
14
+ import importlib.util
15
+ import json
16
+ import os
17
+ import re
18
+ import stat
19
+ import sys
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+
23
+ SCRIPT_DIR = Path(__file__).resolve().parent
24
+
25
+
26
+ def _load_hook_secret_patterns():
27
+ searched = []
28
+ for helper_dir in (SCRIPT_DIR, SCRIPT_DIR.parent / "lib"):
29
+ helper_path = helper_dir / "hook_secret_patterns.py"
30
+ searched.append(str(helper_path))
31
+ if not helper_path.is_file():
32
+ continue
33
+ spec = importlib.util.spec_from_file_location("_claude_token_hook_secret_patterns", helper_path)
34
+ if spec is None or spec.loader is None:
35
+ continue
36
+ module = importlib.util.module_from_spec(spec)
37
+ spec.loader.exec_module(module)
38
+ return module
39
+ raise ImportError("hook_secret_patterns.py not found in " + ", ".join(searched))
40
+
41
+
42
+ _hook_secret_patterns = _load_hook_secret_patterns()
43
+ hook_label_has_sensitive_evidence = _hook_secret_patterns.hook_label_has_sensitive_evidence
44
+
45
+ DEFAULT_CONTEXT_LINES = 3
46
+ DEFAULT_MAX_CHARS = 16_000
47
+ MAX_CONTEXT_LINES_LIMIT = 200
48
+ MIN_MAX_CHARS = 200
49
+ MAX_CHARS_LIMIT = 200_000
50
+ MAX_READ_BYTES = 2_000_000
51
+ BRACE_FALLBACK_LINES = 80
52
+ PATH_LABEL_MAX_CHARS = 80
53
+ ALLOWED_FIRST_ABSOLUTE_SYMLINKS = {
54
+ "tmp": Path("/private/tmp"),
55
+ "var": Path("/private/var"),
56
+ }
57
+
58
+
59
+ def bounded_int(value: object, default: int, minimum: int, maximum: int) -> int:
60
+ try:
61
+ number = int(value)
62
+ except (TypeError, ValueError, OverflowError):
63
+ return default
64
+ return min(max(number, minimum), maximum)
65
+
66
+
67
+ @dataclass
68
+ class SymbolSlice:
69
+ path: str
70
+ symbol: str
71
+ start_line: int
72
+ end_line: int
73
+ language: str
74
+ content: str
75
+ capped: bool = False
76
+ scan_truncated: bool = False
77
+
78
+ def as_dict(self) -> dict[str, object]:
79
+ return {
80
+ "path": self.path,
81
+ "symbol": self.symbol,
82
+ "start_line": self.start_line,
83
+ "end_line": self.end_line,
84
+ "language": self.language,
85
+ "content": self.content,
86
+ "capped": self.capped,
87
+ "scan_truncated": self.scan_truncated,
88
+ }
89
+
90
+
91
+ def path_label(path: Path, show_paths: bool) -> str:
92
+ if show_paths:
93
+ return str(path)
94
+ digest = hashlib.sha256(str(path).encode("utf-8", "replace")).hexdigest()[:12]
95
+ raw_name = path.name or "path"
96
+ name = " ".join(raw_name.strip().split())
97
+ if hook_label_has_sensitive_evidence(raw_name):
98
+ name = "redacted-path"
99
+ elif len(name) > PATH_LABEL_MAX_CHARS:
100
+ name = name[: PATH_LABEL_MAX_CHARS - 15].rstrip() + "...[truncated]"
101
+ return f"{name}#path:{digest}"
102
+
103
+
104
+ def os_error_summary(exc: OSError) -> str:
105
+ parts = [exc.__class__.__name__]
106
+ if getattr(exc, "errno", None) is not None:
107
+ parts.append(f"errno={exc.errno}")
108
+ message = " ".join(str(getattr(exc, "strerror", "") or "").strip().split())
109
+ if message:
110
+ parts.append(message[:160])
111
+ return ": ".join(parts)
112
+
113
+
114
+ def has_symlink_component(path: Path) -> bool:
115
+ """Return True when the requested path traverses an explicit symlink."""
116
+ if path.is_symlink():
117
+ return True
118
+ current = Path(path.anchor) if path.is_absolute() else Path()
119
+ for part in path.parts:
120
+ if path.is_absolute() and part == path.anchor:
121
+ continue
122
+ current = current / part
123
+ if current.is_symlink():
124
+ return True
125
+ return False
126
+
127
+
128
+ def _base_open_flags() -> int:
129
+ flags = os.O_RDONLY
130
+ if hasattr(os, "O_CLOEXEC"):
131
+ flags |= os.O_CLOEXEC
132
+ return flags
133
+
134
+
135
+ def _no_follow_flag() -> int:
136
+ if hasattr(os, "O_NOFOLLOW"):
137
+ return os.O_NOFOLLOW
138
+ raise OSError("platform does not support no-follow file opens")
139
+
140
+
141
+ def _directory_flag() -> int:
142
+ return getattr(os, "O_DIRECTORY", 0)
143
+
144
+
145
+ def _normalized_link_target(parent: Path, raw_target: str) -> Path:
146
+ target = Path(raw_target)
147
+ if not target.is_absolute():
148
+ target = parent / target
149
+ return Path(os.path.normpath(str(target)))
150
+
151
+
152
+ def _normalize_allowed_first_absolute_symlink(path: Path) -> Path:
153
+ """Rewrite narrow platform-owned absolute aliases before no-follow traversal."""
154
+ if not path.is_absolute() or len(path.parts) < 2:
155
+ return path
156
+ first = path.parts[1]
157
+ expected = ALLOWED_FIRST_ABSOLUTE_SYMLINKS.get(first)
158
+ if expected is None:
159
+ return path
160
+ link = Path(path.anchor) / first
161
+ try:
162
+ if not stat.S_ISLNK(os.lstat(link).st_mode):
163
+ return path
164
+ if _normalized_link_target(Path(path.anchor), os.readlink(link)) != expected:
165
+ return path
166
+ except OSError:
167
+ return path
168
+ return expected.joinpath(*path.parts[2:])
169
+
170
+
171
+ def _lstat_at_no_follow(dir_fd: int, component: str, path: Path) -> os.stat_result:
172
+ if os.stat not in getattr(os, "supports_dir_fd", set()):
173
+ raise OSError(errno.ENOSYS, "platform does not support directory-relative no-follow stat", str(path))
174
+ if os.stat not in getattr(os, "supports_follow_symlinks", set()):
175
+ raise OSError(errno.ENOSYS, "platform does not support no-follow stat", str(path))
176
+ return os.stat(component, dir_fd=dir_fd, follow_symlinks=False)
177
+
178
+
179
+ def _open_directory_at(dir_fd: int, component: str, path: Path) -> int:
180
+ component_stat = _lstat_at_no_follow(dir_fd, component, path)
181
+ if stat.S_ISLNK(component_stat.st_mode):
182
+ raise OSError(errno.ELOOP, "symlink path component", str(path))
183
+ if not stat.S_ISDIR(component_stat.st_mode):
184
+ raise OSError(errno.ENOTDIR, "not a directory", str(path))
185
+ flags = _base_open_flags() | _directory_flag() | _no_follow_flag()
186
+ fd = os.open(component, flags, dir_fd=dir_fd)
187
+ try:
188
+ opened = os.fstat(fd)
189
+ if not stat.S_ISDIR(opened.st_mode) or not os.path.samestat(component_stat, opened):
190
+ raise OSError(errno.ELOOP, "path component changed while opening", str(path))
191
+ return fd
192
+ except Exception:
193
+ os.close(fd)
194
+ raise
195
+
196
+
197
+ def _open_regular_no_symlink(path: Path) -> int:
198
+ """Open a regular file without following symlinks in any trusted component."""
199
+ if os.open not in os.supports_dir_fd:
200
+ raise OSError("platform does not support directory-relative no-follow opens")
201
+ path = _normalize_allowed_first_absolute_symlink(path)
202
+ nofollow = _no_follow_flag()
203
+ components = list(path.parts)
204
+ if path.is_absolute() and components:
205
+ components = components[1:]
206
+ if not components:
207
+ raise OSError(f"not a regular file: {path}")
208
+
209
+ root = path.anchor if path.is_absolute() else "."
210
+ dir_fd = os.open(root or ".", _base_open_flags() | _directory_flag())
211
+ try:
212
+ for component in components[:-1]:
213
+ next_fd = _open_directory_at(dir_fd, component, path)
214
+ os.close(dir_fd)
215
+ dir_fd = next_fd
216
+
217
+ before = _lstat_at_no_follow(dir_fd, components[-1], path)
218
+ if stat.S_ISLNK(before.st_mode):
219
+ raise OSError(errno.ELOOP, "symlink path component", str(path))
220
+ if not stat.S_ISREG(before.st_mode):
221
+ raise OSError(errno.EINVAL, "not a regular file", str(path))
222
+ fd = os.open(components[-1], _base_open_flags() | nofollow, dir_fd=dir_fd)
223
+ try:
224
+ opened = os.fstat(fd)
225
+ if not stat.S_ISREG(opened.st_mode) or not os.path.samestat(before, opened):
226
+ raise OSError(errno.ELOOP, "path changed while opening", str(path))
227
+ return fd
228
+ except Exception:
229
+ os.close(fd)
230
+ raise
231
+ finally:
232
+ os.close(dir_fd)
233
+
234
+
235
+ def read_text_bounded(path: Path) -> tuple[str, bool]:
236
+ fd = _open_regular_no_symlink(path)
237
+ try:
238
+ with os.fdopen(fd, "rb") as handle:
239
+ fd = -1
240
+ data = handle.read(MAX_READ_BYTES + 1)
241
+ truncated = len(data) > MAX_READ_BYTES
242
+ if truncated:
243
+ data = data[:MAX_READ_BYTES]
244
+ return data.decode("utf-8", "replace"), truncated
245
+ finally:
246
+ if fd != -1:
247
+ os.close(fd)
248
+
249
+
250
+ def language_for(path: Path) -> str:
251
+ suffix = path.suffix.lower()
252
+ if suffix == ".py":
253
+ return "python"
254
+ if suffix in {".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"}:
255
+ return "javascript"
256
+ if suffix == ".go":
257
+ return "go"
258
+ if suffix == ".rs":
259
+ return "rust"
260
+ return "generic"
261
+
262
+
263
+ def symbol_patterns(symbol: str, language: str) -> list[re.Pattern[str]]:
264
+ escaped = re.escape(symbol)
265
+ if language == "python":
266
+ return [
267
+ re.compile(rf"^(?P<indent>\s*)(?:async\s+)?def\s+{escaped}\b"),
268
+ re.compile(rf"^(?P<indent>\s*)class\s+{escaped}\b"),
269
+ ]
270
+ if language == "javascript":
271
+ return [
272
+ re.compile(rf"^\s*(?:export\s+default\s+)?(?:export\s+)?(?:async\s+)?function\s+{escaped}\b"),
273
+ re.compile(rf"^\s*(?:export\s+)?class\s+{escaped}\b"),
274
+ re.compile(rf"^\s*(?:export\s+)?(?:const|let|var)\s+{escaped}\b"),
275
+ re.compile(rf"^\s*(?:export\s+)?(?:interface|type)\s+{escaped}\b"),
276
+ re.compile(rf"^\s*(?:(?:public|private|protected|static|async|get|set)\s+)*{escaped}\s*\([^;]*\)\s*(?::[^\{{;]+)?\{{"),
277
+ re.compile(rf"^\s*{escaped}\s*:\s*(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|[^,]+=>)"),
278
+ ]
279
+ if language == "go":
280
+ return [
281
+ re.compile(rf"^\s*func\s+(?:\([^)]*\)\s*)?{escaped}\b"),
282
+ re.compile(rf"^\s*type\s+{escaped}\b"),
283
+ ]
284
+ if language == "rust":
285
+ return [
286
+ re.compile(rf"^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+{escaped}\b"),
287
+ re.compile(rf"^\s*(?:pub(?:\([^)]*\))?\s+)?(?:struct|enum|trait|type)\s+{escaped}\b"),
288
+ re.compile(rf"^\s*impl\b.*\b{escaped}\b"),
289
+ ]
290
+ return [re.compile(rf"\b{escaped}\b")]
291
+
292
+
293
+ def find_start(lines: list[str], symbol: str, language: str) -> int | None:
294
+ patterns = symbol_patterns(symbol, language)
295
+ for index, line in enumerate(lines):
296
+ if any(pattern.search(line) for pattern in patterns):
297
+ return index
298
+ return None
299
+
300
+
301
+ def python_block_end(lines: list[str], start: int) -> int:
302
+ indent = len(lines[start]) - len(lines[start].lstrip())
303
+ end = start + 1
304
+ pending_blank_or_comment_end = end
305
+ for index in range(start + 1, len(lines)):
306
+ line = lines[index]
307
+ stripped = line.strip()
308
+ if not stripped:
309
+ pending_blank_or_comment_end = index + 1
310
+ continue
311
+ current_indent = len(line) - len(line.lstrip())
312
+ if stripped.startswith("#"):
313
+ if current_indent > indent:
314
+ end = index + 1
315
+ else:
316
+ pending_blank_or_comment_end = index + 1
317
+ continue
318
+ if current_indent <= indent:
319
+ break
320
+ end = max(index + 1, pending_blank_or_comment_end)
321
+ return max(end, start + 1)
322
+
323
+
324
+ def python_ast_block_end(text: str, symbol: str, start: int) -> int | None:
325
+ try:
326
+ tree = ast.parse(text)
327
+ except SyntaxError:
328
+ return None
329
+ for node in ast.walk(tree):
330
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
331
+ continue
332
+ if node.name != symbol or node.lineno - 1 != start:
333
+ continue
334
+ end_lineno = getattr(node, "end_lineno", None)
335
+ if isinstance(end_lineno, int):
336
+ return max(end_lineno, node.lineno)
337
+ return None
338
+
339
+
340
+ def brace_block_end(lines: list[str], start: int) -> int:
341
+ depth = 0
342
+ started = False
343
+ in_block_comment = False
344
+ for index in range(start, len(lines)):
345
+ line, in_block_comment = strip_line_for_brace_count(lines[index], in_block_comment)
346
+ opens = line.count("{")
347
+ closes = line.count("}")
348
+ if opens:
349
+ started = True
350
+ depth += opens - closes
351
+ if started and depth <= 0:
352
+ return index + 1
353
+ if not started and index >= start and line.strip().endswith((";", ",")):
354
+ return index + 1
355
+ # Heuristic fallback for unmatched braces or deliberately truncated files.
356
+ return min(len(lines), start + BRACE_FALLBACK_LINES)
357
+
358
+
359
+ def strip_line_strings(line: str) -> str:
360
+ # Good enough for brace counting in source snippets; avoids most braces in strings.
361
+ line = re.sub(r'"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'', '""', line)
362
+ line = re.sub(r"`(?:\\.|[^`\\])*`", "``", line)
363
+ return line
364
+
365
+
366
+ def strip_line_for_brace_count(line: str, in_block_comment: bool = False) -> tuple[str, bool]:
367
+ # Track multi-line block comments so braces inside comments do not end a
368
+ # JavaScript/Go/Rust symbol slice before the real closing brace.
369
+ line = strip_line_strings(line)
370
+ output: list[str] = []
371
+ index = 0
372
+ while index < len(line):
373
+ if in_block_comment:
374
+ end = line.find("*/", index)
375
+ if end == -1:
376
+ return "".join(output), True
377
+ index = end + 2
378
+ in_block_comment = False
379
+ continue
380
+ line_comment = line.find("//", index)
381
+ block_comment = line.find("/*", index)
382
+ if line_comment != -1 and (block_comment == -1 or line_comment < block_comment):
383
+ output.append(line[index:line_comment])
384
+ break
385
+ if block_comment == -1:
386
+ output.append(line[index:])
387
+ break
388
+ output.append(line[index:block_comment])
389
+ index = block_comment + 2
390
+ in_block_comment = True
391
+ return "".join(output), in_block_comment
392
+
393
+
394
+ def find_symbol_slice(path: Path, symbol: str, context: int, max_chars: int, show_paths: bool) -> SymbolSlice | None:
395
+ text, scan_truncated = read_text_bounded(path)
396
+ lines = text.splitlines(keepends=True)
397
+ language = language_for(path)
398
+ start = find_start(lines, symbol, language)
399
+ if start is None:
400
+ return None
401
+
402
+ if language == "python":
403
+ end = python_ast_block_end(text, symbol, start) or python_block_end(lines, start)
404
+ elif language in {"javascript", "go", "rust"}:
405
+ end = brace_block_end(lines, start)
406
+ else:
407
+ end = min(len(lines), start + 40)
408
+
409
+ start_with_context = max(0, start - max(0, context))
410
+ end_with_context = min(len(lines), end + max(0, context))
411
+ content = "".join(lines[start_with_context:end_with_context])
412
+ capped = False
413
+ if max_chars > 0 and len(content) > max_chars:
414
+ marker = f"\n[context-guard-kit] symbol slice capped: {len(content)} chars total\n"
415
+ keep = max(0, max_chars - len(marker))
416
+ content = content[:keep].rstrip() + marker
417
+ capped = True
418
+ return SymbolSlice(
419
+ path_label(path.resolve(), show_paths),
420
+ symbol,
421
+ start_with_context + 1,
422
+ end_with_context,
423
+ language,
424
+ content,
425
+ capped,
426
+ scan_truncated,
427
+ )
428
+
429
+
430
+ def print_text(result: SymbolSlice) -> None:
431
+ print(f"[context-guard-kit] {result.path}:{result.start_line}-{result.end_line} symbol={result.symbol} language={result.language}")
432
+ print(result.content, end="" if result.content.endswith("\n") else "\n")
433
+ if result.capped:
434
+ print("[context-guard-kit] rerun with a narrower symbol or larger --max-chars only if necessary.")
435
+ if result.scan_truncated:
436
+ print(f"[context-guard-kit] search scanned only the first {MAX_READ_BYTES} bytes of the file.")
437
+
438
+
439
+ def main() -> int:
440
+ parser = argparse.ArgumentParser(prog="context-guard-read-symbol")
441
+ parser.add_argument("path")
442
+ parser.add_argument("symbol")
443
+ parser.add_argument("--context", type=int, default=DEFAULT_CONTEXT_LINES)
444
+ parser.add_argument("--max-chars", type=int, default=DEFAULT_MAX_CHARS)
445
+ parser.add_argument("--json", action="store_true")
446
+ parser.add_argument("--show-paths", action="store_true", help="show raw absolute paths in output; local debugging only because private paths may be exposed")
447
+ args = parser.parse_args()
448
+
449
+ args.context = bounded_int(args.context, DEFAULT_CONTEXT_LINES, 0, MAX_CONTEXT_LINES_LIMIT)
450
+ args.max_chars = bounded_int(args.max_chars, DEFAULT_MAX_CHARS, MIN_MAX_CHARS, MAX_CHARS_LIMIT)
451
+
452
+ path = Path(args.path).expanduser()
453
+ path = _normalize_allowed_first_absolute_symlink(path)
454
+ safe_path = path_label(path.absolute(), args.show_paths)
455
+ if has_symlink_component(path):
456
+ print(f"context-guard-read-symbol: refusing symlink path component: {safe_path}", file=sys.stderr)
457
+ return 2
458
+ if not path.is_file():
459
+ print(f"context-guard-read-symbol: not a file: {safe_path}", file=sys.stderr)
460
+ return 2
461
+ try:
462
+ result = find_symbol_slice(path, args.symbol, args.context, args.max_chars, args.show_paths)
463
+ except OSError as exc:
464
+ print(f"context-guard-read-symbol: could not read file safely: {safe_path}: {os_error_summary(exc)}", file=sys.stderr)
465
+ return 2
466
+ if result is None:
467
+ suffix = ""
468
+ try:
469
+ if path.stat().st_size > MAX_READ_BYTES:
470
+ suffix = f" in first {MAX_READ_BYTES} bytes; use rg -n to locate a later match"
471
+ except OSError:
472
+ pass
473
+ print(f"context-guard-read-symbol: symbol not found{suffix}: {args.symbol}", file=sys.stderr)
474
+ return 1
475
+ if args.json:
476
+ print(json.dumps(result.as_dict(), indent=2, sort_keys=True, ensure_ascii=False))
477
+ else:
478
+ print_text(result)
479
+ return 0
480
+
481
+
482
+ if __name__ == "__main__":
483
+ raise SystemExit(main())