@ictechgy/context-guard 0.4.11 → 0.4.13
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 +9 -0
- package/README.ko.md +19 -12
- package/README.md +11 -11
- package/package.json +1 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/bin/context-guard +42 -46
- package/plugins/context-guard/bin/context-guard-audit +3 -3
- package/plugins/context-guard/bin/context-guard-bench +136 -16
- package/plugins/context-guard/bin/context-guard-cache-score +29 -2
- package/plugins/context-guard/bin/context-guard-compress +89 -27
- package/plugins/context-guard/bin/context-guard-filter +88 -18
- package/plugins/context-guard/bin/context-guard-pack +28 -2
- package/plugins/context-guard/bin/context-guard-read-symbol +27 -0
- package/plugins/context-guard/bin/context-guard-rewrite-bash +148 -12
- package/plugins/context-guard/bin/context-guard-sanitize-output +169 -6
- package/plugins/context-guard/bin/context-guard-setup +21 -5
- package/plugins/context-guard/bin/context-guard-tool-prune +48 -10
- package/plugins/context-guard/bin/context-guard-trim-output +109 -52
- package/plugins/context-guard/lib/context_guard_command_manifest_loader.py +123 -0
- package/plugins/context-guard/lib/context_guard_commands.py +4 -1
|
@@ -19,6 +19,7 @@ import sys
|
|
|
19
19
|
SHELL_OPERATOR_TOKENS = {";", ";;", ";&", ";;&", "&", "&&", "|", "||", "<", ">", "<<", ">>", "<>", "(", ")"}
|
|
20
20
|
SHELL_OPERATOR_CHARS = frozenset(";&|<>()")
|
|
21
21
|
ENV_ASSIGNMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=.*")
|
|
22
|
+
SAFE_PIPE_FILTER_BASENAMES = frozenset({"cat", "head", "tail", "wc", "sort", "uniq"})
|
|
22
23
|
WRAPPER_BASENAMES = frozenset({
|
|
23
24
|
"trim_command_output.py",
|
|
24
25
|
"context-guard-trim-output",
|
|
@@ -164,6 +165,59 @@ def split_single_safe_command(command: str) -> list[str] | None:
|
|
|
164
165
|
return argv
|
|
165
166
|
|
|
166
167
|
|
|
168
|
+
def split_safe_sanitizer_pipeline(command: str) -> list[list[str]] | None:
|
|
169
|
+
"""Return argv segments for a narrow read-only pipeline safe to sanitizer-wrap.
|
|
170
|
+
|
|
171
|
+
Compound search/diff/log commands are useful in practice (`git diff | cat`,
|
|
172
|
+
`rg token . | head`), but arbitrary shell operators can branch output to
|
|
173
|
+
files/network or change control flow before the sanitizer sees it. This
|
|
174
|
+
helper therefore allows only plain `|` pipelines where the first segment is
|
|
175
|
+
sanitizer-worthy and every later segment is a simple stdout filter. It
|
|
176
|
+
intentionally rejects redirection, here-doc/string, `tee`, `curl`, `&&`,
|
|
177
|
+
command substitution, and other shell syntax.
|
|
178
|
+
"""
|
|
179
|
+
if not command.strip():
|
|
180
|
+
return None
|
|
181
|
+
if any(char in command for char in "\n\r\t`"):
|
|
182
|
+
return None
|
|
183
|
+
if "$(" in command or "${" in command:
|
|
184
|
+
return None
|
|
185
|
+
try:
|
|
186
|
+
lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
|
|
187
|
+
lexer.whitespace_split = True
|
|
188
|
+
tokens = list(lexer)
|
|
189
|
+
except ValueError:
|
|
190
|
+
return None
|
|
191
|
+
if "|" not in tokens:
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
segments: list[list[str]] = [[]]
|
|
195
|
+
for token in tokens:
|
|
196
|
+
is_operator = token in SHELL_OPERATOR_TOKENS or (
|
|
197
|
+
any(char in SHELL_OPERATOR_CHARS for char in token)
|
|
198
|
+
and all(char in SHELL_OPERATOR_CHARS for char in token)
|
|
199
|
+
)
|
|
200
|
+
if is_operator:
|
|
201
|
+
if token != "|":
|
|
202
|
+
return None
|
|
203
|
+
if not segments[-1]:
|
|
204
|
+
return None
|
|
205
|
+
segments.append([])
|
|
206
|
+
continue
|
|
207
|
+
if any(char in token for char in "`\n\r\t"):
|
|
208
|
+
return None
|
|
209
|
+
if "$(" in token or "${" in token:
|
|
210
|
+
return None
|
|
211
|
+
segments[-1].append(token)
|
|
212
|
+
if not segments or not segments[-1] or len(segments) < 2:
|
|
213
|
+
return None
|
|
214
|
+
if not (is_sanitizable_output_command(segments[0]) or is_log_streaming_command(segments[0])):
|
|
215
|
+
return None
|
|
216
|
+
if not all(is_safe_pipe_filter(segment) for segment in segments[1:]):
|
|
217
|
+
return None
|
|
218
|
+
return segments
|
|
219
|
+
|
|
220
|
+
|
|
167
221
|
def command_basename(command: str) -> str:
|
|
168
222
|
return os.path.basename(command)
|
|
169
223
|
|
|
@@ -214,6 +268,70 @@ def npm_script_args(rest: list[str]) -> list[str]:
|
|
|
214
268
|
return rest[i:]
|
|
215
269
|
|
|
216
270
|
|
|
271
|
+
def _filter_args_are_stdin_only(first: str, args: list[str]) -> bool:
|
|
272
|
+
"""Accept small, option-only filter argv forms that do not name files."""
|
|
273
|
+
if first == "cat":
|
|
274
|
+
return not args
|
|
275
|
+
long_no_value_options = {
|
|
276
|
+
"head": set(),
|
|
277
|
+
"tail": set(),
|
|
278
|
+
"wc": {"--bytes", "--chars", "--lines", "--words"},
|
|
279
|
+
"sort": {"--ignore-leading-blanks", "--dictionary-order", "--ignore-case", "--general-numeric-sort", "--human-numeric-sort", "--numeric-sort", "--reverse", "--unique"},
|
|
280
|
+
"uniq": {"--count", "--repeated", "--unique", "--ignore-case"},
|
|
281
|
+
}.get(first, set())
|
|
282
|
+
short_no_value_chars = {
|
|
283
|
+
"head": set(),
|
|
284
|
+
"tail": {"f", "F", "r"},
|
|
285
|
+
"wc": {"c", "m", "l", "w"},
|
|
286
|
+
"sort": {"b", "d", "f", "g", "h", "n", "r", "u"},
|
|
287
|
+
"uniq": {"c", "d", "u", "i"},
|
|
288
|
+
}.get(first, set())
|
|
289
|
+
value_options = {"-n", "--lines", "-c", "--bytes"} if first in {"head", "tail"} else set()
|
|
290
|
+
i = 0
|
|
291
|
+
while i < len(args):
|
|
292
|
+
arg = args[i]
|
|
293
|
+
if arg == "--":
|
|
294
|
+
return i == len(args) - 1
|
|
295
|
+
if arg.startswith("--") and "=" in arg:
|
|
296
|
+
name, value = arg.split("=", 1)
|
|
297
|
+
if name not in value_options:
|
|
298
|
+
return False
|
|
299
|
+
if not re.fullmatch(r"[+-]?\d+[KkMmGg]?", value):
|
|
300
|
+
return False
|
|
301
|
+
i += 1
|
|
302
|
+
continue
|
|
303
|
+
if arg in value_options:
|
|
304
|
+
if i + 1 >= len(args):
|
|
305
|
+
return False
|
|
306
|
+
if not re.fullmatch(r"[+-]?\d+[KkMmGg]?", args[i + 1]):
|
|
307
|
+
return False
|
|
308
|
+
i += 2
|
|
309
|
+
continue
|
|
310
|
+
if arg in long_no_value_options:
|
|
311
|
+
i += 1
|
|
312
|
+
continue
|
|
313
|
+
if arg.startswith("--"):
|
|
314
|
+
return False
|
|
315
|
+
if arg.startswith("-") and arg != "-":
|
|
316
|
+
if not set(arg[1:]).issubset(short_no_value_chars):
|
|
317
|
+
return False
|
|
318
|
+
i += 1
|
|
319
|
+
continue
|
|
320
|
+
if arg.startswith("-"):
|
|
321
|
+
return False
|
|
322
|
+
return False
|
|
323
|
+
return True
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def is_safe_pipe_filter(argv: list[str]) -> bool:
|
|
327
|
+
if not argv:
|
|
328
|
+
return False
|
|
329
|
+
first = command_basename(argv[0])
|
|
330
|
+
if first not in SAFE_PIPE_FILTER_BASENAMES:
|
|
331
|
+
return False
|
|
332
|
+
return _filter_args_are_stdin_only(first, argv[1:])
|
|
333
|
+
|
|
334
|
+
|
|
217
335
|
def is_noisy_command(argv: list[str]) -> bool:
|
|
218
336
|
argv = strip_env_prefix(argv)
|
|
219
337
|
if not argv:
|
|
@@ -423,6 +541,16 @@ def build_sanitized_command(wrapper: str, command: str) -> str:
|
|
|
423
541
|
return shlex.join(wrapped_argv)
|
|
424
542
|
|
|
425
543
|
|
|
544
|
+
def print_updated_command(wrapped: str) -> None:
|
|
545
|
+
response = {
|
|
546
|
+
"hookSpecificOutput": {
|
|
547
|
+
"hookEventName": "PreToolUse",
|
|
548
|
+
"updatedInput": {"command": wrapped},
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
print(json.dumps(response, ensure_ascii=False))
|
|
552
|
+
|
|
553
|
+
|
|
426
554
|
def main() -> int:
|
|
427
555
|
if any(arg in {"-h", "--help"} for arg in sys.argv[1:]):
|
|
428
556
|
print("ContextGuard helper: context-guard-rewrite-bash")
|
|
@@ -450,11 +578,25 @@ def main() -> int:
|
|
|
450
578
|
argv = split_single_safe_command(command)
|
|
451
579
|
if not argv:
|
|
452
580
|
if unparseable_command_needs_sanitizer(command):
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
581
|
+
safe_pipeline = split_safe_sanitizer_pipeline(command)
|
|
582
|
+
if safe_pipeline is None:
|
|
583
|
+
deny(
|
|
584
|
+
"Search/diff/log command contains shell operators that are not in ContextGuard's "
|
|
585
|
+
"read-only pipe allowlist. Simplify to a plain pipeline ending in cat/head/tail/wc/sort/uniq, "
|
|
586
|
+
"run context-guard-sanitize-output explicitly after review, or set "
|
|
587
|
+
f"{FAIL_OPEN_ENV}=1 to run unsanitized intentionally."
|
|
588
|
+
)
|
|
589
|
+
return 0
|
|
590
|
+
wrapper = find_wrapper("sanitize")
|
|
591
|
+
if wrapper is None:
|
|
592
|
+
deny(
|
|
593
|
+
"Search/diff/log command blocked because it contains shell operators and "
|
|
594
|
+
"context-guard-sanitize-output is not installed next to context-guard-rewrite-bash. "
|
|
595
|
+
"Install the sanitizer or set "
|
|
596
|
+
f"{FAIL_OPEN_ENV}=1 to run unsanitized intentionally."
|
|
597
|
+
)
|
|
598
|
+
return 0
|
|
599
|
+
print_updated_command(build_sanitized_command(wrapper, command))
|
|
458
600
|
return 0
|
|
459
601
|
print_noop()
|
|
460
602
|
return 0
|
|
@@ -490,13 +632,7 @@ def main() -> int:
|
|
|
490
632
|
print("{}")
|
|
491
633
|
return 0
|
|
492
634
|
|
|
493
|
-
|
|
494
|
-
"hookSpecificOutput": {
|
|
495
|
-
"hookEventName": "PreToolUse",
|
|
496
|
-
"updatedInput": {"command": wrapped},
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
print(json.dumps(response, ensure_ascii=False))
|
|
635
|
+
print_updated_command(wrapped)
|
|
500
636
|
return 0
|
|
501
637
|
|
|
502
638
|
|
|
@@ -49,9 +49,17 @@ PRIVATE_KEY_END_RE = re.compile(
|
|
|
49
49
|
AUTH_HEADER_RE = re.compile(
|
|
50
50
|
r"(?i)^(?P<prefix>\s*(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:Proxy-)?Authorization\s*:\s*).+$"
|
|
51
51
|
)
|
|
52
|
+
COOKIE_HEADER_RE = re.compile(
|
|
53
|
+
r"(?i)^(?P<prefix>\s*(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:Set-)?Cookie\s*:\s*).+$"
|
|
54
|
+
)
|
|
55
|
+
SESSION_SECRET_KEY = (
|
|
56
|
+
r"(?:session(?:[_-]?(?:id|token))?|sessionid|sid|jsessionid|"
|
|
57
|
+
r"csrf(?:[_-]?token)?|xsrf(?:[_-]?token)?)"
|
|
58
|
+
)
|
|
52
59
|
SECRET_KEY = (
|
|
53
60
|
r"[A-Za-z0-9_.-]*(?:api[_-]?key|apikey|token|secret|password|passwd|pwd|"
|
|
54
61
|
r"private[_-]?key|access[_-]?key|client[_-]?secret)[A-Za-z0-9_.-]*"
|
|
62
|
+
rf"|{SESSION_SECRET_KEY}"
|
|
55
63
|
r"|AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN|"
|
|
56
64
|
r"GOOGLE_APPLICATION_CREDENTIALS|AZURE_CLIENT_SECRET"
|
|
57
65
|
)
|
|
@@ -61,11 +69,48 @@ INLINE_QUOTED_SECRET_ASSIGNMENT_RE = re.compile(
|
|
|
61
69
|
rf"[\"']?(?:{SECRET_KEY})[\"']?\s*[:=]\s*)"
|
|
62
70
|
rf"(?P<quote>[\"'])(?P<value>(?:\\.|(?!(?P=quote)).)*)(?P=quote)(?P<tail>[^\s,;}}\]]*)"
|
|
63
71
|
)
|
|
72
|
+
CODE_IDENTIFIER = r"[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*"
|
|
73
|
+
CALL_ARGUMENT_CHUNK = r"(?:[^()\"'\n;]+|\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*'|\([^()]*\))*"
|
|
74
|
+
INLINE_UNQUOTED_CALL_SECRET_ASSIGNMENT_RE = re.compile(
|
|
75
|
+
rf"(?i)(?P<lead>^|[\s;{{\[,])"
|
|
76
|
+
rf"(?P<prefix>(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
|
|
77
|
+
rf"[\"']?(?:{SECRET_KEY})[\"']?\s*[:=]\s*)"
|
|
78
|
+
rf"(?P<value>(?![\"']){CODE_IDENTIFIER}\({CALL_ARGUMENT_CHUNK}\))"
|
|
79
|
+
)
|
|
80
|
+
SECRET_IDENTIFIER_PART = (
|
|
81
|
+
r"(?:[A-Za-z_$][A-Za-z0-9_$]*(?:api_?key|apikey|token|secret|password|passwd|pwd|"
|
|
82
|
+
r"private_?key|access_?key|client_?secret|sessionid|session_id|session_token|"
|
|
83
|
+
r"csrf_token|xsrf_token)[A-Za-z0-9_$]*|session|sid|csrf|xsrf)"
|
|
84
|
+
)
|
|
85
|
+
FALLBACK_SECRET_OPERAND = rf"(?:[A-Za-z_$][A-Za-z0-9_$]*\.)*{SECRET_IDENTIFIER_PART}"
|
|
86
|
+
INLINE_UNQUOTED_FALLBACK_SECRET_ASSIGNMENT_RE = re.compile(
|
|
87
|
+
rf"(?i)(?P<lead>^|[\s;{{\[,])"
|
|
88
|
+
rf"(?P<prefix>(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
|
|
89
|
+
rf"[\"']?(?:{SECRET_KEY})[\"']?\s*[:=]\s*)"
|
|
90
|
+
rf"(?P<value>(?![\"']|\[REDACTED\])"
|
|
91
|
+
rf"[^;\n]*?(?:\bor\b|\|\||\?\?|\belse\b|\?[^:\n;]*:)\s*"
|
|
92
|
+
rf"(?:[\"'](?:\\.|[^\"'\\])*[\"']|{FALLBACK_SECRET_OPERAND})[^;\n]*)"
|
|
93
|
+
)
|
|
94
|
+
INLINE_UNQUOTED_BRACKETED_SECRET_ASSIGNMENT_RE = re.compile(
|
|
95
|
+
rf"(?i)(?P<lead>^|[\s;{{\[,])"
|
|
96
|
+
rf"(?P<prefix>(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
|
|
97
|
+
rf"[\"']?(?:{SECRET_KEY})[\"']?\s*[:=]\s*)"
|
|
98
|
+
rf"(?P<value>(?![\"']|\[REDACTED\])"
|
|
99
|
+
rf"[^\s,;}}\]]*(?:\([^;\n]*?\)|\{{[^;\n]*?\}}|\[[^;\n]*?\])[^\s,;}}\]]*)"
|
|
100
|
+
)
|
|
64
101
|
INLINE_UNQUOTED_SECRET_ASSIGNMENT_RE = re.compile(
|
|
65
102
|
rf"(?i)(?P<lead>^|[\s;{{\[,])"
|
|
66
103
|
rf"(?P<prefix>(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
|
|
67
104
|
rf"[\"']?(?:{SECRET_KEY})[\"']?\s*[:=]\s*)"
|
|
68
|
-
rf"(?P<value>[^\s,;}}\]]+)"
|
|
105
|
+
rf"(?P<value>(?![\"']|\[REDACTED\])[^\s,;}}\]]+)"
|
|
106
|
+
)
|
|
107
|
+
UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_RE = re.compile(
|
|
108
|
+
rf"(?i)(?:^|[\s;{{\[,])"
|
|
109
|
+
rf"(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
|
|
110
|
+
rf"[\"']?(?:{SECRET_KEY})[\"']?\s*[:=]\s*(?P<value>(?![\"']).*)$"
|
|
111
|
+
)
|
|
112
|
+
CONTINUATION_OPERATOR_RE = re.compile(
|
|
113
|
+
r"(?i)(?:\\|\|\||&&|\?\?|[+*/%&|^?,]|\?|:|\bor\b|\band\b|\belse\b)\s*(?://.*|#.*)?$"
|
|
69
114
|
)
|
|
70
115
|
URL_LIKE_RE = re.compile(r"\b[A-Za-z][A-Za-z0-9+.-]*://[^\s]+")
|
|
71
116
|
URL_SECRET_PARAM_RE = re.compile(rf"(?i)([?&#;](?:{SECRET_KEY})=)[^\s?&#;]+")
|
|
@@ -80,6 +125,43 @@ SAFE_UNQUOTED_VALUES = {
|
|
|
80
125
|
"undefined",
|
|
81
126
|
}
|
|
82
127
|
IDENTIFIER_CHAIN_RE = re.compile(r"^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)+$")
|
|
128
|
+
SAFE_ENV_LOOKUP_CALL_RE = re.compile(r"^(?:os\.getenv|os\.environ\.get)\(\s*[\"'][A-Za-z0-9_.-]{1,80}[\"']\s*\)$")
|
|
129
|
+
SAFE_RE_COMPILE_CALL_RE = re.compile(r"^re\.compile\([^;\n]*\)$")
|
|
130
|
+
SAFE_CODE_EXPRESSION_CALL_RE = re.compile(rf"^{CODE_IDENTIFIER}\(\s*(?:{CODE_IDENTIFIER}(?:\s*,\s*{CODE_IDENTIFIER})*)?\s*\)$")
|
|
131
|
+
GETTER_CALL_RE = re.compile(rf"^{CODE_IDENTIFIER}\.get\(\s*[\"'](?P<key>[A-Za-z0-9_.-]{{1,80}})[\"']\s*\)$")
|
|
132
|
+
CAMEL_ACRONYM_BOUNDARY_RE = re.compile(r"(?<=[A-Z])(?=[A-Z][a-z])")
|
|
133
|
+
CAMEL_WORD_BOUNDARY_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
|
|
134
|
+
SAFE_GETTER_KEY_NAMES = {
|
|
135
|
+
"access_key",
|
|
136
|
+
"access_token",
|
|
137
|
+
"api_key",
|
|
138
|
+
"apikey",
|
|
139
|
+
"auth",
|
|
140
|
+
"authorization",
|
|
141
|
+
"aws_access_key_id",
|
|
142
|
+
"aws_secret_access_key",
|
|
143
|
+
"aws_session_token",
|
|
144
|
+
"azure_client_secret",
|
|
145
|
+
"client_id",
|
|
146
|
+
"client_secret",
|
|
147
|
+
"cookie",
|
|
148
|
+
"credential",
|
|
149
|
+
"credentials",
|
|
150
|
+
"csrf",
|
|
151
|
+
"google_application_credentials",
|
|
152
|
+
"jwt",
|
|
153
|
+
"password",
|
|
154
|
+
"passwd",
|
|
155
|
+
"private_key",
|
|
156
|
+
"pwd",
|
|
157
|
+
"refresh_token",
|
|
158
|
+
"secret",
|
|
159
|
+
"session",
|
|
160
|
+
"session_id",
|
|
161
|
+
"sessionid",
|
|
162
|
+
"sid",
|
|
163
|
+
"token",
|
|
164
|
+
}
|
|
83
165
|
INLINE_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
|
84
166
|
(re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+"), "[REDACTED]"),
|
|
85
167
|
(re.compile(r"(?i)\bBasic\s+[A-Za-z0-9._~+/=-]+"), "[REDACTED]"),
|
|
@@ -171,20 +253,33 @@ def cap_line(line: str, max_line_chars: int) -> tuple[str, bool]:
|
|
|
171
253
|
return body[:keep] + marker + newline, True
|
|
172
254
|
|
|
173
255
|
|
|
256
|
+
def normalize_getter_key(key: str) -> str:
|
|
257
|
+
key = CAMEL_ACRONYM_BOUNDARY_RE.sub("_", key)
|
|
258
|
+
key = CAMEL_WORD_BOUNDARY_RE.sub("_", key)
|
|
259
|
+
key = re.sub(r"[_.-]+", "_", key)
|
|
260
|
+
return re.sub(r"_+", "_", key).strip("_").lower()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def is_safe_getter_key(key: str) -> bool:
|
|
264
|
+
return normalize_getter_key(key) in SAFE_GETTER_KEY_NAMES
|
|
265
|
+
|
|
266
|
+
|
|
174
267
|
def should_redact_unquoted_secret_value(line: str, match: re.Match[str]) -> bool:
|
|
175
268
|
value = match.group("value").strip()
|
|
269
|
+
prefix = match.group("prefix")
|
|
176
270
|
if not value:
|
|
177
271
|
return False
|
|
178
272
|
if value.lower() in SAFE_UNQUOTED_VALUES:
|
|
179
273
|
return False
|
|
180
274
|
if IDENTIFIER_CHAIN_RE.match(value):
|
|
181
275
|
return False
|
|
182
|
-
|
|
183
|
-
if end < len(line) and line[end] in "([{":
|
|
184
|
-
# Likely a function call or expression (`api_key = os.getenv(...)`);
|
|
185
|
-
# preserve it so Claude can still reason about code flow.
|
|
276
|
+
if SAFE_ENV_LOOKUP_CALL_RE.match(value) or SAFE_RE_COMPILE_CALL_RE.match(value):
|
|
186
277
|
return False
|
|
187
|
-
|
|
278
|
+
getter_match = GETTER_CALL_RE.match(value)
|
|
279
|
+
if re.search(r"\s[:=]\s*$", prefix) and (
|
|
280
|
+
SAFE_CODE_EXPRESSION_CALL_RE.match(value)
|
|
281
|
+
or (getter_match is not None and is_safe_getter_key(getter_match.group("key")))
|
|
282
|
+
):
|
|
188
283
|
return False
|
|
189
284
|
return True
|
|
190
285
|
|
|
@@ -218,6 +313,9 @@ def redact_secret_assignments(line: str) -> tuple[str, bool]:
|
|
|
218
313
|
return f"{match.group('lead')}{match.group('prefix')}[REDACTED]"
|
|
219
314
|
|
|
220
315
|
line = INLINE_QUOTED_SECRET_ASSIGNMENT_RE.sub(quoted_repl, line)
|
|
316
|
+
line = INLINE_UNQUOTED_FALLBACK_SECRET_ASSIGNMENT_RE.sub(unquoted_repl, line)
|
|
317
|
+
line = INLINE_UNQUOTED_CALL_SECRET_ASSIGNMENT_RE.sub(unquoted_repl, line)
|
|
318
|
+
line = INLINE_UNQUOTED_BRACKETED_SECRET_ASSIGNMENT_RE.sub(unquoted_repl, line)
|
|
221
319
|
line = INLINE_UNQUOTED_SECRET_ASSIGNMENT_RE.sub(unquoted_repl, line)
|
|
222
320
|
return line, redacted
|
|
223
321
|
|
|
@@ -257,6 +355,54 @@ def detect_multiline_secret_assignment(line: str) -> str | None:
|
|
|
257
355
|
return None
|
|
258
356
|
|
|
259
357
|
|
|
358
|
+
def expression_bracket_delta(text: str) -> int:
|
|
359
|
+
delta = 0
|
|
360
|
+
quote: str | None = None
|
|
361
|
+
escaped = False
|
|
362
|
+
for char in text:
|
|
363
|
+
if quote is not None:
|
|
364
|
+
if escaped:
|
|
365
|
+
escaped = False
|
|
366
|
+
elif char == "\\":
|
|
367
|
+
escaped = True
|
|
368
|
+
elif char == quote:
|
|
369
|
+
quote = None
|
|
370
|
+
continue
|
|
371
|
+
if char in {"'", '"'}:
|
|
372
|
+
quote = char
|
|
373
|
+
elif char in "([{":
|
|
374
|
+
delta += 1
|
|
375
|
+
elif char in ")}]":
|
|
376
|
+
delta -= 1
|
|
377
|
+
return delta
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def ends_with_continuation_operator(text: str) -> bool:
|
|
381
|
+
return bool(CONTINUATION_OPERATOR_RE.search(text.rstrip()))
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def detect_multiline_secret_expression(line: str) -> int | None:
|
|
385
|
+
marker = UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_RE.search(line)
|
|
386
|
+
if marker is None:
|
|
387
|
+
return None
|
|
388
|
+
value = marker.group("value").strip()
|
|
389
|
+
if not value:
|
|
390
|
+
return 0
|
|
391
|
+
delta = expression_bracket_delta(value)
|
|
392
|
+
if delta > 0:
|
|
393
|
+
return delta
|
|
394
|
+
if ends_with_continuation_operator(value):
|
|
395
|
+
return max(delta, 0)
|
|
396
|
+
return None
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def update_multiline_secret_expression_state(line: str, depth: int) -> int | None:
|
|
400
|
+
next_depth = max(0, depth + expression_bracket_delta(line))
|
|
401
|
+
if next_depth == 0 and not ends_with_continuation_operator(line):
|
|
402
|
+
return None
|
|
403
|
+
return next_depth
|
|
404
|
+
|
|
405
|
+
|
|
260
406
|
def private_key_state_after_line(line: str) -> bool | None:
|
|
261
407
|
"""Return updated private-key state for a line, or None when no marker appears."""
|
|
262
408
|
if PRIVATE_KEY_BEGIN_RE.search(line):
|
|
@@ -277,6 +423,7 @@ class LineSanitizer:
|
|
|
277
423
|
self.show_paths = show_paths
|
|
278
424
|
self.in_private_key_block = False
|
|
279
425
|
self.multiline_secret_quote: str | None = None
|
|
426
|
+
self.multiline_secret_expression_depth: int | None = None
|
|
280
427
|
self.redactions = 0
|
|
281
428
|
|
|
282
429
|
def sanitize(self, raw_line: str) -> tuple[str, bool]:
|
|
@@ -309,6 +456,12 @@ class LineSanitizer:
|
|
|
309
456
|
self.in_private_key_block = False
|
|
310
457
|
return self._finish(diff_prefix + "[REDACTED PRIVATE KEY BLOCK]\n", redacted)
|
|
311
458
|
|
|
459
|
+
if self.multiline_secret_expression_depth is not None:
|
|
460
|
+
self.multiline_secret_expression_depth = update_multiline_secret_expression_state(
|
|
461
|
+
line, self.multiline_secret_expression_depth
|
|
462
|
+
)
|
|
463
|
+
return self._finish(diff_prefix + "[REDACTED MULTILINE SECRET]\n", True)
|
|
464
|
+
|
|
312
465
|
multiline_quote = detect_multiline_secret_assignment(line)
|
|
313
466
|
if multiline_quote is not None:
|
|
314
467
|
self.multiline_secret_quote = multiline_quote
|
|
@@ -323,11 +476,21 @@ class LineSanitizer:
|
|
|
323
476
|
self.in_private_key_block = True
|
|
324
477
|
return self._finish(diff_prefix + "[REDACTED PRIVATE KEY BLOCK]\n", redacted)
|
|
325
478
|
|
|
479
|
+
expression_depth = detect_multiline_secret_expression(line)
|
|
480
|
+
if expression_depth is not None:
|
|
481
|
+
self.multiline_secret_expression_depth = expression_depth
|
|
482
|
+
return self._finish(diff_prefix + "[REDACTED MULTILINE SECRET]\n", True)
|
|
483
|
+
|
|
326
484
|
new_line, count = AUTH_HEADER_RE.subn(r"\g<prefix>[REDACTED]", line)
|
|
327
485
|
if count:
|
|
328
486
|
redacted = True
|
|
329
487
|
line = new_line
|
|
330
488
|
|
|
489
|
+
new_line, count = COOKIE_HEADER_RE.subn(r"\g<prefix>[REDACTED]", line)
|
|
490
|
+
if count:
|
|
491
|
+
redacted = True
|
|
492
|
+
line = new_line
|
|
493
|
+
|
|
331
494
|
line, assignment_redacted = redact_secret_assignments(line)
|
|
332
495
|
if assignment_redacted:
|
|
333
496
|
redacted = True
|
|
@@ -2210,6 +2210,25 @@ def backup_existing(path: Path) -> Path | None:
|
|
|
2210
2210
|
return backup
|
|
2211
2211
|
|
|
2212
2212
|
|
|
2213
|
+
def rollback_restore_guidance(settings_path: Path, backup_path: Path | None, original_existed: bool) -> str:
|
|
2214
|
+
if backup_path is not None:
|
|
2215
|
+
return (
|
|
2216
|
+
"Restore only with a no-follow, symlink-safe copy that opens the backup and target parent "
|
|
2217
|
+
"without following links, then atomically replaces the target; do not use generic shell "
|
|
2218
|
+
f"copy/delete commands on this mutable target. Backup: {backup_path}. Target: {settings_path}."
|
|
2219
|
+
)
|
|
2220
|
+
if original_existed:
|
|
2221
|
+
return (
|
|
2222
|
+
"No backup path was recorded; inspect the target with no-follow file operations before any "
|
|
2223
|
+
f"manual recovery. Do not use generic shell copy/delete commands on this mutable target: {settings_path}."
|
|
2224
|
+
)
|
|
2225
|
+
return (
|
|
2226
|
+
"The target did not exist before setup. If cleanup is required, verify the target and every parent "
|
|
2227
|
+
"without following symlinks and remove only the verified regular file; do not use generic shell "
|
|
2228
|
+
f"delete commands on this mutable target: {settings_path}."
|
|
2229
|
+
)
|
|
2230
|
+
|
|
2231
|
+
|
|
2213
2232
|
def write_rollback_record(
|
|
2214
2233
|
*,
|
|
2215
2234
|
root: Path,
|
|
@@ -2237,11 +2256,8 @@ def write_rollback_record(
|
|
|
2237
2256
|
"target_path": str(settings_path),
|
|
2238
2257
|
"backup_path": str(backup_path) if backup_path else None,
|
|
2239
2258
|
"original_existed": original_existed,
|
|
2240
|
-
"restore": (
|
|
2241
|
-
|
|
2242
|
-
if backup_path
|
|
2243
|
-
else f"rm -f {shlex.quote(str(settings_path))}"
|
|
2244
|
-
),
|
|
2259
|
+
"restore": rollback_restore_guidance(settings_path, backup_path, original_existed),
|
|
2260
|
+
"restore_requires_no_follow": True,
|
|
2245
2261
|
}
|
|
2246
2262
|
atomic_write(rollback_path, json.dumps(record, indent=2, sort_keys=True) + "\n", 0o600)
|
|
2247
2263
|
return rollback_id, rollback_path
|
|
@@ -87,6 +87,8 @@ class Candidate:
|
|
|
87
87
|
index: int
|
|
88
88
|
score: float = 0.0
|
|
89
89
|
rank: int = 0
|
|
90
|
+
schema_bytes: int = 0
|
|
91
|
+
parameter_terms: frozenset[str] | None = None
|
|
90
92
|
|
|
91
93
|
|
|
92
94
|
def fail(message: str) -> NoReturn:
|
|
@@ -276,7 +278,15 @@ def tool_schema_from_dict(raw: dict[str, Any], *, fallback_name: str | None = No
|
|
|
276
278
|
schema["description"] = description
|
|
277
279
|
if server and "server" not in schema:
|
|
278
280
|
schema["server"] = server
|
|
279
|
-
return Candidate(
|
|
281
|
+
return Candidate(
|
|
282
|
+
name=name,
|
|
283
|
+
server=cap_text(server, MAX_LABEL_CHARS) if server else None,
|
|
284
|
+
description=description,
|
|
285
|
+
schema=schema,
|
|
286
|
+
index=index,
|
|
287
|
+
schema_bytes=byte_len_json(schema),
|
|
288
|
+
parameter_terms=frozenset(terms(" ".join(collect_parameter_text(schema)))),
|
|
289
|
+
)
|
|
280
290
|
|
|
281
291
|
|
|
282
292
|
def normalize_catalog(raw: Any) -> list[Candidate]:
|
|
@@ -362,7 +372,11 @@ def score_candidate(candidate: Candidate, query_terms: set[str]) -> float:
|
|
|
362
372
|
return 0.0
|
|
363
373
|
name_terms = terms(candidate.name)
|
|
364
374
|
desc_terms = terms(candidate.description)
|
|
365
|
-
parameter_terms =
|
|
375
|
+
parameter_terms = (
|
|
376
|
+
set(candidate.parameter_terms)
|
|
377
|
+
if candidate.parameter_terms is not None
|
|
378
|
+
else terms(" ".join(collect_parameter_text(candidate.schema)))
|
|
379
|
+
)
|
|
366
380
|
score = 0.0
|
|
367
381
|
score += 4.0 * len(query_terms & name_terms)
|
|
368
382
|
score += 1.5 * len(query_terms & desc_terms)
|
|
@@ -379,14 +393,38 @@ def rank_candidates(candidates: list[Candidate], query: str) -> list[Candidate]:
|
|
|
379
393
|
query_terms = terms(query)
|
|
380
394
|
scored: list[Candidate] = []
|
|
381
395
|
for cand in candidates:
|
|
382
|
-
scored.append(Candidate(
|
|
396
|
+
scored.append(Candidate(
|
|
397
|
+
cand.name,
|
|
398
|
+
cand.server,
|
|
399
|
+
cand.description,
|
|
400
|
+
cand.schema,
|
|
401
|
+
cand.index,
|
|
402
|
+
score_candidate(cand, query_terms),
|
|
403
|
+
0,
|
|
404
|
+
schema_bytes=cand.schema_bytes,
|
|
405
|
+
parameter_terms=cand.parameter_terms,
|
|
406
|
+
))
|
|
383
407
|
scored.sort(key=lambda item: (-item.score, item.index))
|
|
384
408
|
ranked: list[Candidate] = []
|
|
385
409
|
for rank, cand in enumerate(scored, start=1):
|
|
386
|
-
ranked.append(Candidate(
|
|
410
|
+
ranked.append(Candidate(
|
|
411
|
+
cand.name,
|
|
412
|
+
cand.server,
|
|
413
|
+
cand.description,
|
|
414
|
+
cand.schema,
|
|
415
|
+
cand.index,
|
|
416
|
+
cand.score,
|
|
417
|
+
rank,
|
|
418
|
+
schema_bytes=cand.schema_bytes,
|
|
419
|
+
parameter_terms=cand.parameter_terms,
|
|
420
|
+
))
|
|
387
421
|
return ranked
|
|
388
422
|
|
|
389
423
|
|
|
424
|
+
def candidate_schema_bytes(cand: Candidate) -> int:
|
|
425
|
+
return cand.schema_bytes if cand.schema_bytes > 0 else byte_len_json(cand.schema)
|
|
426
|
+
|
|
427
|
+
|
|
390
428
|
def normalized_link_target(parent: Path, raw_target: str) -> Path:
|
|
391
429
|
target = Path(raw_target)
|
|
392
430
|
if not target.is_absolute():
|
|
@@ -707,7 +745,7 @@ def build_payload(receipt_id: str, ranked: list[Candidate], query: str, redactio
|
|
|
707
745
|
"description": cand.description,
|
|
708
746
|
"score": cand.score,
|
|
709
747
|
"rank": cand.rank,
|
|
710
|
-
"schema_bytes":
|
|
748
|
+
"schema_bytes": candidate_schema_bytes(cand),
|
|
711
749
|
"schema": cand.schema,
|
|
712
750
|
}
|
|
713
751
|
for cand in ranked
|
|
@@ -739,7 +777,7 @@ def retrieval_command(receipt_id: str, *, store_dir: str, tool_name: str | None
|
|
|
739
777
|
|
|
740
778
|
|
|
741
779
|
def selected_tool_record(cand: Candidate, receipt_id: str, budget_left: int, *, store_dir: str) -> tuple[dict[str, Any], int]:
|
|
742
|
-
schema_size =
|
|
780
|
+
schema_size = candidate_schema_bytes(cand)
|
|
743
781
|
record: dict[str, Any] = {
|
|
744
782
|
"name": cand.name,
|
|
745
783
|
"server": cand.server,
|
|
@@ -765,7 +803,7 @@ def deferred_tool_record(cand: Candidate, receipt_id: str, *, store_dir: str) ->
|
|
|
765
803
|
"score": cand.score,
|
|
766
804
|
"rank": cand.rank,
|
|
767
805
|
"description": cand.description,
|
|
768
|
-
"schema_bytes":
|
|
806
|
+
"schema_bytes": candidate_schema_bytes(cand),
|
|
769
807
|
"reason": "deferred_after_core_top",
|
|
770
808
|
"retrieval": retrieval_command(receipt_id, store_dir=store_dir, tool_name=cand.name),
|
|
771
809
|
}
|
|
@@ -1008,9 +1046,9 @@ def defer_report(args: argparse.Namespace) -> str:
|
|
|
1008
1046
|
store_dir=args.store_dir,
|
|
1009
1047
|
namespace_top=namespace_top,
|
|
1010
1048
|
)
|
|
1011
|
-
all_schema_bytes = sum(
|
|
1012
|
-
listed_deferred_schema_bytes = sum(
|
|
1013
|
-
total_deferred_schema_bytes = sum(
|
|
1049
|
+
all_schema_bytes = sum(candidate_schema_bytes(cand) for cand in ranked)
|
|
1050
|
+
listed_deferred_schema_bytes = sum(candidate_schema_bytes(cand) for cand in deferred_candidates)
|
|
1051
|
+
total_deferred_schema_bytes = sum(candidate_schema_bytes(cand) for cand in ranked[core_top:])
|
|
1014
1052
|
tool_stub_report_bytes = byte_len_json(core_tools) + byte_len_json(deferred_tools)
|
|
1015
1053
|
all_schema_tokens = proxy_tokens(all_schema_bytes)
|
|
1016
1054
|
inline_core_schema_tokens = proxy_tokens(core_schema_bytes)
|