switchroom 0.19.35 → 0.19.36

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 (29) hide show
  1. package/dist/cli/skill-validate-pretool.mjs +15 -2
  2. package/dist/cli/switchroom.js +672 -441
  3. package/dist/host-control/main.js +149 -3
  4. package/package.json +4 -2
  5. package/profiles/_base/start.sh.hbs +37 -12
  6. package/telegram-plugin/dist/gateway/gateway.js +311 -93
  7. package/telegram-plugin/format.ts +70 -15
  8. package/telegram-plugin/gateway/gateway.ts +22 -29
  9. package/telegram-plugin/gateway/ipc-server.ts +18 -15
  10. package/telegram-plugin/gateway/model-command.ts +34 -0
  11. package/telegram-plugin/gateway/outbound-send-path.ts +12 -1
  12. package/telegram-plugin/operator-events.ts +40 -16
  13. package/telegram-plugin/secret-detect/db-uri.ts +90 -0
  14. package/telegram-plugin/secret-detect/index.ts +24 -1
  15. package/telegram-plugin/secret-detect/inert-values.ts +147 -0
  16. package/telegram-plugin/secret-detect/kv-scanner.ts +108 -0
  17. package/telegram-plugin/secret-detect/patterns.ts +24 -4
  18. package/telegram-plugin/tests/format-consistency.test.ts +93 -0
  19. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +40 -2
  20. package/telegram-plugin/tests/ipc-server-validate-operator.test.ts +20 -11
  21. package/telegram-plugin/tests/outbound-send-path.test.ts +1 -1
  22. package/telegram-plugin/tests/secret-detect-cross-engine.test.ts +263 -0
  23. package/telegram-plugin/tests/secret-detect-write-path.test.ts +403 -0
  24. package/telegram-plugin/tests/turn-flush-safety.test.ts +2 -2
  25. package/vendor/hindsight-memory/scripts/lib/client.py +58 -0
  26. package/vendor/hindsight-memory/scripts/lib/secret_patterns.json +431 -0
  27. package/vendor/hindsight-memory/scripts/lib/secret_redact.py +563 -0
  28. package/vendor/hindsight-memory/scripts/lib/secret_redaction_vectors.json +397 -0
  29. package/vendor/hindsight-memory/scripts/tests/test_secret_redact.py +522 -0
@@ -0,0 +1,563 @@
1
+ """Write-path secret redaction for the Hindsight plugin.
2
+
3
+ WHY THIS EXISTS
4
+ ---------------
5
+ Switchroom ships a secret redactor (``telegram-plugin/secret-detect/``)
6
+ that masks credentials before they are persisted to the Telegram message
7
+ store, the issues log and hostd output. It is TypeScript, and it never ran
8
+ on the path that writes agent MEMORY — every retain in this plugin is
9
+ Python, so credentials that appeared in a turn were stored in Hindsight
10
+ verbatim.
11
+
12
+ This module is the Python half of that redactor, wired at the ONE function
13
+ every retain in this plugin goes through (``lib/client.py``:
14
+ ``HindsightClient.retain`` before splitting, plus ``_request`` as a
15
+ structural backstop for any future caller that skips ``retain``).
16
+
17
+ NO FORKED PATTERN LIST
18
+ ----------------------
19
+ The regex table is NOT written here. It is loaded from
20
+ ``secret_patterns.json``, which is GENERATED from
21
+ ``telegram-plugin/secret-detect/patterns.ts`` by
22
+ ``scripts/gen-secret-patterns.ts`` and pinned by the ``bun lint`` guard
23
+ ``scripts/check-secret-pattern-parity.ts``. A pattern added once therefore
24
+ covers the Telegram gate, the issues pipeline AND memory intake.
25
+
26
+ That byte-compare proves the SOURCES match. It does not prove the
27
+ BEHAVIOUR matches — identical regex source means different things to
28
+ ``re`` and to ``RegExp`` (see the semantics bridge below), which is how
29
+ the ``\b``-boundary fork shipped past a green CI in the first place.
30
+ Behaviour parity is a separate, weaker guarantee, held up by the shared
31
+ vectors in ``secret_redaction_vectors.json`` and by the differential test
32
+ ``telegram-plugin/tests/secret-detect-cross-engine.test.ts``, which runs
33
+ BOTH engines over the same corpus and compares their output.
34
+
35
+ The three rules that need imperative gates rather than a bare regex —
36
+ the KEY=VALUE entropy heuristic, connection-URI credentials and
37
+ human-memorable passwords — are ported by hand below and pinned against
38
+ their TypeScript counterparts by the shared behaviour vectors in
39
+ ``secret_redaction_vectors.json`` (asserted by
40
+ ``scripts/tests/test_secret_redact.py`` here and by
41
+ ``telegram-plugin/tests/secret-detect-write-path.test.ts`` there).
42
+
43
+ DELIBERATE DIFFERENCES FROM THE TS ENGINE
44
+ -----------------------------------------
45
+ * The generic high-entropy fallback is not ported. ``redact()`` in
46
+ TypeScript explicitly filters ``generic_high_entropy`` out before
47
+ masking (it is a low-precision "ask the user" signal, not a mask
48
+ signal), so a redact-only engine has no use for it.
49
+ * No 32 KB chunking. The TS engine windows large inputs to bound ReDoS on
50
+ a hot Telegram path; here we scan the whole string, which is strictly
51
+ more coverage, and the patterns are all linear-time shapes.
52
+ * The suppressor (test/example/fixture demotion) is not ported: it only
53
+ demotes confidence, and ``redact()`` masks suppressed hits anyway.
54
+
55
+ FAIL-CLOSED
56
+ -----------
57
+ If the pattern table is missing or unparseable, ``redact()`` raises at
58
+ import time rather than silently becoming a no-op. A redactor that fails
59
+ open is worse than no redactor, because callers stop looking.
60
+ """
61
+
62
+ import json
63
+ import math
64
+ import os
65
+ import re
66
+ from typing import Dict, List, Optional, Tuple
67
+
68
+ REDACTED_MARKER = "[REDACTED]"
69
+
70
+ _PATTERNS_FILE = os.path.join(os.path.dirname(__file__), "secret_patterns.json")
71
+
72
+ # ─── JS/Python regex semantics bridge ─────────────────────────────────
73
+ #
74
+ # The pattern SOURCES are byte-identical across the two engines (that is
75
+ # what `check-secret-pattern-parity` proves), but identical source is not
76
+ # identical behaviour: `re` and `RegExp` disagree on what the shorthand
77
+ # classes MEAN.
78
+ #
79
+ # `\b`, `\w`, `\d`, and case-folding under `re.I`
80
+ # Python `str` patterns are UNICODE-aware by default, JavaScript
81
+ # `RegExp` without the `u` flag is ASCII-only. Every anchored rule
82
+ # here is `\b`-delimited, so `sk-ant-...<CJK char>` was a word
83
+ # boundary in JS (masked) and NOT one in Python (stored verbatim).
84
+ # Fixed by compiling with `re.ASCII`.
85
+ #
86
+ # Note the flag arithmetic below: `re.UNICODE` is implicit on `str`
87
+ # patterns and `flags | re.ASCII` alone raises
88
+ # `ValueError: ASCII and UNICODE flags are incompatible`.
89
+ #
90
+ # `\s` / `\S`
91
+ # This one runs the OTHER way. JavaScript `\s` is Unicode-aware
92
+ # even without the `u` flag (NBSP, ideographic space, BOM all
93
+ # match), so plain `re.ASCII` would narrow Python's `\s` below
94
+ # JS's and re-open the fork on the separator instead of the
95
+ # boundary. `_js_compat_source` therefore rewrites `\s`/`\S` to an
96
+ # explicit class carrying exactly the ECMAScript WhiteSpace +
97
+ # LineTerminator set, so the two agree under `re.ASCII`.
98
+ #
99
+ # The rewrite happens HERE rather than in the codegen on purpose: keeping
100
+ # `secret_patterns.json` a faithful mirror of `patterns.ts` is what makes
101
+ # the byte-compare guard readable, and the semantics bridge is a property
102
+ # of the Python engine, not of the shared table.
103
+
104
+ # ECMAScript WhiteSpace + LineTerminator (what JS `\s` matches).
105
+ _JS_WS = (
106
+ " \\t\\n\\x0b\\x0c\\r"
107
+ "\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff"
108
+ )
109
+
110
+
111
+ def _js_compat_source(src: str) -> str:
112
+ """Rewrite `\\s`/`\\S` so they mean under `re.ASCII` what they mean to
113
+ a JavaScript `RegExp` without the `u` flag.
114
+
115
+ `[\\s\\S]` (the "any character including newlines" idiom) is left
116
+ alone: `\\S` cannot be expressed inside a character class, and the
117
+ union is every character in either engine regardless of flags.
118
+ """
119
+ out: List[str] = []
120
+ i = 0
121
+ n = len(src)
122
+ in_class = False
123
+ while i < n:
124
+ ch = src[i]
125
+ if ch == "\\" and i + 1 < n:
126
+ nxt = src[i + 1]
127
+ if nxt == "s":
128
+ out.append(_JS_WS if in_class else "[" + _JS_WS + "]")
129
+ i += 2
130
+ continue
131
+ if nxt == "S" and not in_class:
132
+ out.append("[^" + _JS_WS + "]")
133
+ i += 2
134
+ continue
135
+ out.append(src[i : i + 2])
136
+ i += 2
137
+ continue
138
+ if ch == "[" and not in_class:
139
+ in_class = True
140
+ elif ch == "]" and in_class:
141
+ in_class = False
142
+ out.append(ch)
143
+ i += 1
144
+ return "".join(out)
145
+
146
+
147
+ def _compile(source: str, flags: int = 0) -> "re.Pattern[str]":
148
+ """Compile with JavaScript-compatible shorthand-class semantics."""
149
+ return re.compile(_js_compat_source(source), flags & ~re.UNICODE | re.ASCII)
150
+
151
+
152
+ def _load_patterns() -> List[dict]:
153
+ with open(_PATTERNS_FILE, "r", encoding="utf-8") as fh:
154
+ doc = json.load(fh)
155
+ patterns = doc.get("patterns")
156
+ if not isinstance(patterns, list) or not patterns:
157
+ raise ValueError(f"{_PATTERNS_FILE} has no patterns array")
158
+ compiled = []
159
+ for p in patterns:
160
+ flags = 0
161
+ if p.get("ignore_case"):
162
+ flags |= re.I
163
+ if p.get("multiline"):
164
+ flags |= re.M
165
+ compiled.append(
166
+ {
167
+ "rule_id": p["rule_id"],
168
+ "regex": _compile(p["source"], flags),
169
+ "capture_index": int(p.get("capture_index", 0)),
170
+ }
171
+ )
172
+ return compiled
173
+
174
+
175
+ _COMPILED_PATTERNS = _load_patterns()
176
+
177
+ # ─── Gates mirrored from the TypeScript engine ────────────────────────
178
+
179
+ # index.ts — env_key_value shape gate.
180
+ ENV_KV_MIN_LEN = 12
181
+ ENV_KV_MIN_ENTROPY = 3.5
182
+ # kv-scanner.ts — KV_ENTROPY_THRESHOLD.
183
+ KV_ENTROPY_THRESHOLD = 4.0
184
+ # kv-scanner.ts — MEMORABLE_PW_MIN_CLASSES.
185
+ MEMORABLE_PW_MIN_CLASSES = 2
186
+
187
+ _KV_RE = _compile(
188
+ r"\b([A-Za-z_][A-Za-z0-9_-]*(?:password|passwd|token|secret|key|api[_-]?key))"
189
+ r"\s*[:=]\s*[\"']?([^\s\"'\\]{8,})[\"']?",
190
+ re.I,
191
+ )
192
+
193
+ _MEMORABLE_PW_RE = _compile(
194
+ r"\b([A-Za-z0-9_-]*(?:password|passwd|passphrase|pwd))\b"
195
+ r"\s*(?:[:=]\s*|\s+is\s+)([\"']?)([^\s\"']{8,64})\2",
196
+ re.I,
197
+ )
198
+
199
+ # `<scheme>://<user>:<password>@<host>` — see db-uri.ts for the shape
200
+ # rationale. The password class deliberately ADMITS `@` and stops only at
201
+ # an authority terminator (`/`, `?`, `#`, whitespace); greedy matching
202
+ # then backs off to the LAST `@`, so an unencoded `@` inside the password
203
+ # cannot leave the tail of the password outside the masked span.
204
+ _DB_URI_RE = _compile(
205
+ r"\b([a-zA-Z][a-zA-Z0-9+.-]+)://([^\s:/@]+):([^\s/?#]+)@([^\s/@?#]+)"
206
+ )
207
+
208
+ # Placeholders, variable references and already-masked text. Redacting
209
+ # these buys nothing and destroys the meaning of the surrounding line —
210
+ # and for a `vault:` reference it deletes exactly the key NAME an agent
211
+ # is supposed to remember. Mirrors INERT_VALUE_RE in
212
+ # telegram-plugin/secret-detect/inert-values.ts.
213
+ # ``$`` is spelled ``\Z`` throughout: Python's ``$`` also matches BEFORE a
214
+ # final newline, JavaScript's (without ``/m``) does not. Every entry here
215
+ # is end-anchored, so that difference would be a live cross-engine fork.
216
+ _INERT_VALUE_RES = [
217
+ _compile(r"^\[REDACTED(?::[A-Za-z0-9_]+)?\]\Z", re.I),
218
+ _compile(r"^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?\Z"),
219
+ _compile(r"^\{\{[^\n\r\u2028\u2029]*\}\}\Z"),
220
+ _compile(r"^<[^<>\n\r\u2028\u2029]*>\Z"),
221
+ _compile(r"^%[A-Za-z_][A-Za-z0-9_]*%\Z"),
222
+ _compile(r"^vault:[A-Za-z0-9_./-]*\Z", re.I),
223
+ _compile(r"^[*x\u2022.]+\Z", re.I),
224
+ _compile(
225
+ r"^(?:process\.env|import\.meta\.env|os\.environ)"
226
+ r"(?:\.[A-Za-z_$][A-Za-z0-9_$]*"
227
+ r"|\[[\"']?[A-Za-z_$][A-Za-z0-9_$]*[\"']?\])*\Z"
228
+ ),
229
+ _compile(
230
+ r"^(?:changeme|change-me|replaceme|replace-me|placeholder|todo|tbd"
231
+ r"|yourkey|your-key|yourpassword|your-password|yoursecret|your-secret"
232
+ r"|yourtoken|your-token)(?:[-_][A-Za-z0-9-]+)?\Z",
233
+ re.I,
234
+ ),
235
+ _compile(r"^[a-z]+(?: [a-z]+){2,}\Z"),
236
+ ]
237
+
238
+ # An unbroken run of ``[A-Za-z0-9]`` long enough to BE a credential.
239
+ # Mirrors CREDENTIAL_RUN_RE / hasCredentialShapedRun in inert-values.ts.
240
+ _CREDENTIAL_RUN_RE = _compile(r"[A-Za-z0-9]{12,}")
241
+ # Mixed-class floor = ENV_KV_MIN_LEN. Single-class runs (only letters or
242
+ # only digits) are far more likely to be English words, so they get more
243
+ # rope -- but not unlimited: no word in the protected corpus reaches 16.
244
+ _MIXED_CLASS_RUN_MIN = 12
245
+ _SINGLE_CLASS_RUN_MIN = 16
246
+
247
+ # Trailing punctuation an English sentence puts AFTER a value. The value
248
+ # classes are `[^\s"']`-shaped, so a sentence-final `.` or a list comma
249
+ # is captured as part of the value; stripping it before the shape gates
250
+ # is what keeps "The password is required." out of the redactor.
251
+ _TRAILING_PUNCT_RE = re.compile(r"[.,;:!?)\]}]+$")
252
+
253
+
254
+ def _strip_trailing_punctuation(value: str) -> str:
255
+ return _TRAILING_PUNCT_RE.sub("", value)
256
+
257
+
258
+ def _run_is_credential_shaped(run: str) -> bool:
259
+ classes = 0
260
+ if re.search(r"[a-z]", run):
261
+ classes += 1
262
+ if re.search(r"[A-Z]", run):
263
+ classes += 1
264
+ if re.search(r"[0-9]", run):
265
+ classes += 1
266
+ floor = _MIXED_CLASS_RUN_MIN if classes >= 2 else _SINGLE_CLASS_RUN_MIN
267
+ return len(run) >= floor
268
+
269
+
270
+ def has_credential_shaped_run(value: str) -> bool:
271
+ """True when ``value`` carries a credential-shaped run anywhere inside.
272
+
273
+ #3982 review, MAJOR 7. The inert SHAPES are necessary but not
274
+ sufficient: ``<a8f3...>`` and ``{{tok_...}}`` are legal instances of
275
+ the ``<...>`` / ``{{...}}`` shapes, and a wrapper left behind after
276
+ someone filled the value in (``vault:pg/password<secret>``,
277
+ ``[REDACTED]<secret>``, ``changeme-<secret>``) turned a
278
+ false-positive fix into a bypass of already-shipped coverage.
279
+ """
280
+ return any(
281
+ _run_is_credential_shaped(m.group(0))
282
+ for m in _CREDENTIAL_RUN_RE.finditer(value)
283
+ )
284
+
285
+
286
+ def is_inert_value(value: str) -> bool:
287
+ if not any(r.search(value) for r in _INERT_VALUE_RES):
288
+ return False
289
+ return not has_credential_shaped_run(value)
290
+
291
+
292
+ # Rules whose captured value is a LABELLED slot: an inert placeholder in
293
+ # that slot is documentation, not a credential. See MAJOR 5 of the #3982
294
+ # review — `POSTGRES_PASSWORD: vault:pg/password` was being destroyed
295
+ # while the lowercase spelling of the same line was left alone.
296
+ _INERT_GATED_RULES = frozenset({"env_key_value", "json_secret_field", "cli_flag"})
297
+
298
+ # url-redact.ts — SENSITIVE_PARAMS.
299
+ _SENSITIVE_PARAMS = {
300
+ "token",
301
+ "key",
302
+ "api_key",
303
+ "apikey",
304
+ "secret",
305
+ "access_token",
306
+ "password",
307
+ "pass",
308
+ "auth",
309
+ "client_secret",
310
+ "refresh_token",
311
+ "signature",
312
+ }
313
+
314
+ _URL_RE = _compile(r"\b(?:https?|wss?|ftp)://[^\s<>\"']+", re.I)
315
+
316
+
317
+ def shannon_entropy(value: str) -> float:
318
+ """Shannon entropy in bits/char — mirrors secret-detect/entropy.ts."""
319
+ if not value:
320
+ return 0.0
321
+ counts: Dict[str, int] = {}
322
+ for ch in value:
323
+ counts[ch] = counts.get(ch, 0) + 1
324
+ n = len(value)
325
+ total = 0.0
326
+ for c in counts.values():
327
+ p = c / n
328
+ total -= p * math.log2(p)
329
+ return total
330
+
331
+
332
+ def _char_class_count(value: str) -> int:
333
+ n = 0
334
+ if re.search(r"[a-z]", value):
335
+ n += 1
336
+ if re.search(r"[A-Z]", value):
337
+ n += 1
338
+ if re.search(r"[0-9]", value):
339
+ n += 1
340
+ if re.search(r"[^A-Za-z0-9]", value):
341
+ n += 1
342
+ return n
343
+
344
+
345
+ def _looks_like_memorable_password(value: str) -> bool:
346
+ """Port of looksLikeMemorablePassword() in kv-scanner.ts."""
347
+ if is_inert_value(value):
348
+ return False
349
+ core = _strip_trailing_punctuation(value)
350
+ if len(core) < 8 or len(core) > 64:
351
+ return False
352
+ if is_inert_value(core):
353
+ return False
354
+ if len(set(core)) < 4:
355
+ return False
356
+ return _char_class_count(core) >= MEMORABLE_PW_MIN_CLASSES
357
+
358
+
359
+ # ─── URL credential scrub (port of url-redact.ts) ─────────────────────
360
+
361
+
362
+ def _redact_one_url(raw: str) -> Optional[str]:
363
+ """Mask userinfo + sensitive query params in ONE url, or return None
364
+ when nothing changed. Deliberately string-level: unlike the TS side we
365
+ do not round-trip through a URL parser, because Python's urlsplit /
366
+ urlunsplit normalizes in ways WHATWG does not and the two engines must
367
+ agree byte-for-byte on the common shapes.
368
+ """
369
+ changed = False
370
+ scheme_sep = raw.find("://")
371
+ if scheme_sep < 0:
372
+ return None
373
+ rest = raw[scheme_sep + 3 :]
374
+ # Authority runs to the first /, ?, or #.
375
+ auth_end = len(rest)
376
+ for ch in "/?#":
377
+ i = rest.find(ch)
378
+ if i >= 0:
379
+ auth_end = min(auth_end, i)
380
+ authority = rest[:auth_end]
381
+ tail = rest[auth_end:]
382
+ at = authority.rfind("@")
383
+ if at >= 0:
384
+ authority = "***@" + authority[at + 1 :]
385
+ changed = True
386
+ # Sensitive query params.
387
+ if "?" in tail:
388
+ path, _, query_and_frag = tail.partition("?")
389
+ query, hashsep, frag = query_and_frag.partition("#")
390
+ parts = []
391
+ for pair in query.split("&"):
392
+ key, eq, _val = pair.partition("=")
393
+ if eq and key.lower() in _SENSITIVE_PARAMS:
394
+ parts.append(f"{key}=***")
395
+ changed = True
396
+ else:
397
+ parts.append(pair)
398
+ tail = path + "?" + "&".join(parts) + hashsep + frag
399
+ if not changed:
400
+ return None
401
+ return raw[: scheme_sep + 3] + authority + tail
402
+
403
+
404
+ def redact_urls(text: str) -> str:
405
+ def _sub(m: "re.Match[str]") -> str:
406
+ replaced = _redact_one_url(m.group(0))
407
+ return replaced if replaced is not None else m.group(0)
408
+
409
+ return _URL_RE.sub(_sub, text)
410
+
411
+
412
+ # ─── Detection ────────────────────────────────────────────────────────
413
+
414
+
415
+ class _Hit:
416
+ __slots__ = ("rule_id", "start", "end")
417
+
418
+ def __init__(self, rule_id: str, start: int, end: int) -> None:
419
+ self.rule_id = rule_id
420
+ self.start = start
421
+ self.end = end
422
+
423
+
424
+ def _pattern_hits(text: str) -> List[_Hit]:
425
+ hits: List[_Hit] = []
426
+ for p in _COMPILED_PATTERNS:
427
+ idx = p["capture_index"]
428
+ for m in p["regex"].finditer(text):
429
+ if not m.group(0):
430
+ continue
431
+ cap = m.group(0) if idx == 0 else m.group(idx)
432
+ if not cap:
433
+ continue
434
+ start = m.start(0) if idx == 0 else m.start(idx)
435
+ if start < 0:
436
+ continue
437
+ if p["rule_id"] in _INERT_GATED_RULES and is_inert_value(cap):
438
+ continue
439
+ if p["rule_id"] == "env_key_value":
440
+ if len(cap) < ENV_KV_MIN_LEN:
441
+ continue
442
+ if shannon_entropy(cap) < ENV_KV_MIN_ENTROPY:
443
+ continue
444
+ hits.append(_Hit(p["rule_id"], start, start + len(cap)))
445
+ return hits
446
+
447
+
448
+ def _kv_hits(text: str) -> List[_Hit]:
449
+ hits: List[_Hit] = []
450
+ for m in _KV_RE.finditer(text):
451
+ value = m.group(2)
452
+ if not value:
453
+ continue
454
+ # Mirrors scanKeyValue() in kv-scanner.ts — placeholders and code
455
+ # references are not credentials.
456
+ if is_inert_value(value):
457
+ continue
458
+ if shannon_entropy(value) < KV_ENTROPY_THRESHOLD:
459
+ continue
460
+ hits.append(_Hit("kv_entropy", m.start(2), m.end(2)))
461
+ return hits
462
+
463
+
464
+ def _db_uri_hits(text: str) -> List[_Hit]:
465
+ hits: List[_Hit] = []
466
+ for m in _DB_URI_RE.finditer(text):
467
+ password = m.group(3)
468
+ if password.startswith("[REDACTED") or re.fullmatch(
469
+ r"[*x]+", password, re.I | re.A
470
+ ):
471
+ continue
472
+ hits.append(_Hit("db_uri_password", m.start(3), m.end(3)))
473
+ return hits
474
+
475
+
476
+ def _memorable_password_hits(text: str) -> List[_Hit]:
477
+ hits: List[_Hit] = []
478
+ for m in _MEMORABLE_PW_RE.finditer(text):
479
+ value = m.group(3)
480
+ if not value or not _looks_like_memorable_password(value):
481
+ continue
482
+ # The WHOLE captured value is masked, trailing punctuation
483
+ # included: once the gates have decided this is a credential, a
484
+ # trailing `!` is far more likely to be the last byte of the
485
+ # password than the end of a sentence, and losing a period from
486
+ # inside an already-redacted span costs nothing.
487
+ hits.append(_Hit("memorable_password", m.start(3), m.end(3)))
488
+ return hits
489
+
490
+
491
+ def _marker(rule_id: str) -> str:
492
+ """Port of redactedMarker() in redact.ts."""
493
+ trimmed = re.sub(r"^(kv|env)_", "", rule_id)
494
+ if not trimmed or trimmed in ("key_value", "kv_entropy"):
495
+ return REDACTED_MARKER
496
+ return f"[REDACTED:{trimmed}]"
497
+
498
+
499
+ def _resolve(hits: List[_Hit]) -> List[_Hit]:
500
+ """Keep a non-overlapping set, preferring the earliest start and, on a
501
+ tie, the widest span. Overlapping replacements would corrupt offsets;
502
+ the TS engine gets the same effect from dedupeRaw + dropOverlaps.
503
+ """
504
+ ordered = sorted(hits, key=lambda h: (h.start, -(h.end - h.start)))
505
+ kept: List[_Hit] = []
506
+ last_end = -1
507
+ for h in ordered:
508
+ if h.start < last_end:
509
+ continue
510
+ kept.append(h)
511
+ last_end = h.end
512
+ return kept
513
+
514
+
515
+ def redact(text: Optional[str]) -> Optional[str]:
516
+ """Mask every detected secret in ``text``, leaving prose intact.
517
+
518
+ Returns the input unchanged when nothing matched. ``None`` and empty
519
+ strings pass through so callers can apply this to optional fields
520
+ without branching.
521
+ """
522
+ if not text:
523
+ return text
524
+ scrubbed = redact_urls(text)
525
+ hits = (
526
+ _pattern_hits(scrubbed)
527
+ + _kv_hits(scrubbed)
528
+ + _db_uri_hits(scrubbed)
529
+ + _memorable_password_hits(scrubbed)
530
+ )
531
+ if not hits:
532
+ return scrubbed
533
+ out = scrubbed
534
+ for h in sorted(_resolve(hits), key=lambda x: x.start, reverse=True):
535
+ out = out[: h.start] + _marker(h.rule_id) + out[h.end :]
536
+ return out
537
+
538
+
539
+ def redact_metadata(value):
540
+ """Recursively redact string leaves of a JSON-shaped metadata value.
541
+
542
+ Retain metadata carries free-form provenance (source paths, tool
543
+ names, transcript excerpts on some producers), so it is a real leak
544
+ surface even though the memory ROW is built from ``content``. Keys are
545
+ left alone — they are structural, and rewriting them would break
546
+ consumers that look them up by name.
547
+ """
548
+ if isinstance(value, str):
549
+ return redact(value)
550
+ if isinstance(value, list):
551
+ return [redact_metadata(v) for v in value]
552
+ if isinstance(value, dict):
553
+ return {k: redact_metadata(v) for k, v in value.items()}
554
+ return value
555
+
556
+
557
+ def redact_retain_fields(
558
+ content: Optional[str],
559
+ context: Optional[str] = None,
560
+ metadata: Optional[dict] = None,
561
+ ) -> Tuple[Optional[str], Optional[str], Optional[dict]]:
562
+ """Redact every free-text field a retain POST carries."""
563
+ return redact(content), redact(context), redact_metadata(metadata)