@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
|
@@ -1,43 +0,0 @@
|
|
|
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)
|
|
@@ -1,483 +0,0 @@
|
|
|
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())
|