@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.
@@ -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
- deny(
454
- "Search/diff/log command contains shell operators that cannot be safely rewritten. "
455
- "Run the command through context-guard-sanitize-output explicitly, simplify it, or set "
456
- f"{FAIL_OPEN_ENV}=1 to run unsanitized intentionally."
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
- response = {
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
- end = match.end("value")
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
- if any(ch in value for ch in "()[]{}"):
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
- f"cp {shlex.quote(str(backup_path))} {shlex.quote(str(settings_path))}"
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(name=name, server=cap_text(server, MAX_LABEL_CHARS) if server else None, description=description, schema=schema, index=index)
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 = terms(" ".join(collect_parameter_text(candidate.schema)))
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(cand.name, cand.server, cand.description, cand.schema, cand.index, score_candidate(cand, query_terms), 0))
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(cand.name, cand.server, cand.description, cand.schema, cand.index, cand.score, rank))
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": byte_len_json(cand.schema),
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 = byte_len_json(cand.schema)
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": byte_len_json(cand.schema),
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(byte_len_json(cand.schema) for cand in ranked)
1012
- listed_deferred_schema_bytes = sum(byte_len_json(cand.schema) for cand in deferred_candidates)
1013
- total_deferred_schema_bytes = sum(byte_len_json(cand.schema) for cand in ranked[core_top:])
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)