switchroom 0.19.35 → 0.19.37

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 (35) hide show
  1. package/dist/cli/skill-validate-pretool.mjs +15 -2
  2. package/dist/cli/switchroom.js +678 -444
  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/bridge/bridge.ts +4 -4
  7. package/telegram-plugin/dist/bridge/bridge.js +4 -4
  8. package/telegram-plugin/dist/gateway/gateway.js +317 -95
  9. package/telegram-plugin/dist/server.js +4 -4
  10. package/telegram-plugin/format.ts +100 -29
  11. package/telegram-plugin/gateway/gateway.ts +22 -29
  12. package/telegram-plugin/gateway/ipc-server.ts +18 -15
  13. package/telegram-plugin/gateway/model-command.ts +34 -0
  14. package/telegram-plugin/gateway/outbound-send-path.ts +12 -1
  15. package/telegram-plugin/operator-events.ts +40 -16
  16. package/telegram-plugin/render/unsupported-token-guard.ts +10 -6
  17. package/telegram-plugin/secret-detect/db-uri.ts +90 -0
  18. package/telegram-plugin/secret-detect/index.ts +24 -1
  19. package/telegram-plugin/secret-detect/inert-values.ts +147 -0
  20. package/telegram-plugin/secret-detect/kv-scanner.ts +108 -0
  21. package/telegram-plugin/secret-detect/patterns.ts +24 -4
  22. package/telegram-plugin/tests/format-consistency.test.ts +111 -0
  23. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +40 -2
  24. package/telegram-plugin/tests/ipc-server-validate-operator.test.ts +20 -11
  25. package/telegram-plugin/tests/mcp-instructions-budget.test.ts +8 -1
  26. package/telegram-plugin/tests/outbound-send-path.test.ts +1 -1
  27. package/telegram-plugin/tests/render/unsupported-token-guard.test.ts +36 -5
  28. package/telegram-plugin/tests/secret-detect-cross-engine.test.ts +263 -0
  29. package/telegram-plugin/tests/secret-detect-write-path.test.ts +403 -0
  30. package/telegram-plugin/tests/turn-flush-safety.test.ts +2 -2
  31. package/vendor/hindsight-memory/scripts/lib/client.py +58 -0
  32. package/vendor/hindsight-memory/scripts/lib/secret_patterns.json +431 -0
  33. package/vendor/hindsight-memory/scripts/lib/secret_redact.py +563 -0
  34. package/vendor/hindsight-memory/scripts/lib/secret_redaction_vectors.json +397 -0
  35. package/vendor/hindsight-memory/scripts/tests/test_secret_redact.py +522 -0
@@ -0,0 +1,522 @@
1
+ """Write-path secret redaction — Python half.
2
+
3
+ Two obligations are pinned here:
4
+
5
+ 1. The Python redactor produces byte-identical output to the TypeScript
6
+ one for every case in ``lib/secret_redaction_vectors.json``. The same
7
+ file is asserted by
8
+ ``telegram-plugin/tests/secret-detect-write-path.test.ts``, so a
9
+ divergence between the two engines fails one side or the other.
10
+
11
+ 2. No retain this plugin issues can put a raw credential on the wire.
12
+ The assertions are on the POSTED BODY, not on "the redactor was
13
+ called" — a test that only proved the code path ran would still pass
14
+ if the redaction were a no-op.
15
+
16
+ Every credential-shaped literal below is synthetic and assembled at
17
+ runtime so nothing token-shaped is committed.
18
+ """
19
+
20
+ import json
21
+ import os
22
+ import sys
23
+ import unittest
24
+ import urllib.request
25
+
26
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
27
+
28
+ from lib.client import HindsightClient # noqa: E402
29
+ from lib.secret_redact import ( # noqa: E402
30
+ REDACTED_MARKER,
31
+ is_inert_value,
32
+ redact,
33
+ redact_metadata,
34
+ )
35
+
36
+ VECTORS_PATH = os.path.join(
37
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
38
+ "lib",
39
+ "secret_redaction_vectors.json",
40
+ )
41
+
42
+
43
+ def _pg_uri(password):
44
+ return "postgres://appuser:" + password + "@db.internal:5432/prod"
45
+
46
+
47
+ class SharedVectorTests(unittest.TestCase):
48
+ """Behaviour parity with the TypeScript redactor."""
49
+
50
+ def test_vectors_file_is_present_and_non_trivial(self):
51
+ with open(VECTORS_PATH, "r", encoding="utf-8") as fh:
52
+ doc = json.load(fh)
53
+ self.assertGreaterEqual(len(doc["vectors"]), 50)
54
+
55
+ def test_vectors_cover_the_generated_pattern_table(self):
56
+ """#3982 review, MAJOR 4.
57
+
58
+ The vectors originally shipped exercised only the two hand-ported
59
+ imperative rules plus the URL pass — ZERO of the ~60 GENERATED
60
+ patterns. That is exactly the gap that let a Unicode word-boundary
61
+ fork inside the generated table ship past a green CI.
62
+ """
63
+ with open(VECTORS_PATH, "r", encoding="utf-8") as fh:
64
+ doc = json.load(fh)
65
+ expected = "".join(v["expected"] for v in doc["vectors"])
66
+ for marker in (
67
+ "[REDACTED:anthropic_api_key]",
68
+ "[REDACTED:jwt]",
69
+ "[REDACTED:aws_access_key]",
70
+ "[REDACTED:pem_private_key]",
71
+ "[REDACTED:bearer_auth_header]",
72
+ "[REDACTED:basic_auth_header]",
73
+ ):
74
+ with self.subTest(marker):
75
+ self.assertIn(marker, expected)
76
+ non_ascii = [
77
+ v
78
+ for v in doc["vectors"]
79
+ if any(ord(c) > 127 for c in v["input"]) and "[REDACTED" in v["expected"]
80
+ ]
81
+ self.assertGreaterEqual(len(non_ascii), 4)
82
+
83
+ def test_every_shared_vector_matches(self):
84
+ with open(VECTORS_PATH, "r", encoding="utf-8") as fh:
85
+ doc = json.load(fh)
86
+ for vector in doc["vectors"]:
87
+ with self.subTest(vector["name"]):
88
+ self.assertEqual(redact(vector["input"]), vector["expected"])
89
+
90
+
91
+ class RedactorBehaviourTests(unittest.TestCase):
92
+ def test_database_connection_password_is_removed(self):
93
+ password = "Hunter" + "Two99"
94
+ out = redact("DSN " + _pg_uri(password))
95
+ self.assertNotIn(password, out)
96
+ self.assertIn("[REDACTED:db_uri_password]", out)
97
+ # Host and user survive — the row stays diagnostically useful.
98
+ self.assertIn("appuser", out)
99
+ self.assertIn("db.internal:5432/prod", out)
100
+
101
+ def test_human_memorable_password_is_removed(self):
102
+ password = "Fluffy" + "Barnaby" + "1998"
103
+ out = redact("the wifi password is " + password)
104
+ self.assertNotIn(password, out)
105
+ self.assertIn("[REDACTED:memorable_password]", out)
106
+
107
+ def test_prose_mentioning_passwords_is_untouched(self):
108
+ for prose in (
109
+ "The password policy requires rotation every 90 days.",
110
+ "the password is rotated quarterly",
111
+ "I need the password for staging, can you check the vault?",
112
+ "password: ${DB_PASSWORD}",
113
+ ):
114
+ with self.subTest(prose):
115
+ self.assertEqual(redact(prose), prose)
116
+
117
+ def test_sentence_final_prose_is_untouched(self):
118
+ """#3982 review, BLOCKER 2.
119
+
120
+ The value class is ``[^\\s"']``, so the sentence terminator was
121
+ captured INTO the value and the ``.`` itself supplied the second
122
+ character class the gate asks for. Every string here redacted as a
123
+ credential on the shipped code, and sentence-final is the MOST
124
+ common position for these words — the suite's original
125
+ mid-sentence cases were tests chosen to pass.
126
+ """
127
+ for prose in (
128
+ "The password is required.",
129
+ "The password is incorrect.",
130
+ "The password is unchanged.",
131
+ "The password is different.",
132
+ "The password is protected.",
133
+ "Ken confirmed the password is unchanged.",
134
+ "password: required, minimum twelve characters, mixed case",
135
+ "The passphrase is unchanged; rotate it next quarter.",
136
+ ):
137
+ with self.subTest(prose):
138
+ self.assertEqual(redact(prose), prose)
139
+
140
+ def test_real_password_ending_a_sentence_is_masked_whole(self):
141
+ password = "Mango" + "TreeHouse77"
142
+ self.assertEqual(
143
+ redact("The wifi password is " + password + "."),
144
+ "The wifi password is [REDACTED:memorable_password]",
145
+ )
146
+ # A password whose LAST byte is punctuation keeps that byte masked.
147
+ self.assertEqual(
148
+ redact("password: " + "Fluffy" + "Barnaby" + "1998!"),
149
+ "password: [REDACTED:memorable_password]",
150
+ )
151
+
152
+ def test_dsn_password_with_unencoded_at_sign_is_fully_masked(self):
153
+ """#3982 review, MAJOR 3 — 9 of 11 password bytes were stored."""
154
+ password = "p" + "@" + "ssW0rd123"
155
+ out = redact("postgres://appuser:" + password + "@db.internal:5432/prod")
156
+ self.assertEqual(
157
+ out, "postgres://appuser:[REDACTED:db_uri_password]@db.internal:5432/prod"
158
+ )
159
+ for i in range(0, len(password) - 3):
160
+ self.assertNotIn(password[i : i + 4], out)
161
+
162
+ def test_comma_joined_dsn_list_masks_each_uri_separately(self):
163
+ """Adversarial case for the MAJOR-3 fix.
164
+
165
+ The password class now INCLUDES ``@`` so greedy matching backs off
166
+ to the LAST ``@``. The hazard that introduces is one match
167
+ swallowing two DSNs and hiding the first host; it does not,
168
+ because ``/`` terminates the authority.
169
+ """
170
+ a = "Ab" + "3xQ" + "9zK"
171
+ b = "Zt" + "7wM" + "2vR"
172
+ out = redact(
173
+ "postgres://u1:" + a + "@h1.internal:5432/a,"
174
+ "postgres://u2:" + b + "@h2.internal:5432/b"
175
+ )
176
+ self.assertEqual(
177
+ out,
178
+ "postgres://u1:[REDACTED:db_uri_password]@h1.internal:5432/a,"
179
+ "postgres://u2:[REDACTED:db_uri_password]@h2.internal:5432/b",
180
+ )
181
+
182
+ def test_dsn_password_stops_at_query_and_fragment_delimiters(self):
183
+ pw = "Ab" + "3xQ" + "9zK"
184
+ self.assertEqual(
185
+ redact("mysql://u:" + pw + "@host/db?opt=x@y"),
186
+ "mysql://u:[REDACTED:db_uri_password]@host/db?opt=x@y",
187
+ )
188
+ self.assertEqual(
189
+ redact("redis://u:" + pw + "@host#frag@z"),
190
+ "redis://u:[REDACTED:db_uri_password]@host#frag@z",
191
+ )
192
+
193
+ def test_inert_values_survive_regardless_of_case(self):
194
+ """#3982 review, MAJOR 5.
195
+
196
+ The inert-value list applied ONLY to the new memorable_password
197
+ rule, so LETTER CASE decided whether a vault reference survived.
198
+ A vault key NAME is exactly what an agent is supposed to remember.
199
+ """
200
+ for line in (
201
+ "POSTGRES_PASSWORD: vault:pg/password",
202
+ "postgres_password: vault:pg/password",
203
+ "ANTHROPIC_API_KEY: vault:anthropic/api_key",
204
+ "PASSWORD: ${DB_PASSWORD}",
205
+ "password: ${DB_PASSWORD}",
206
+ "API_TOKEN=%API_TOKEN%",
207
+ "JWT_SECRET=<generate-with-openssl-rand>",
208
+ "DB_PASSWORD=changeme-in-production",
209
+ "const API_KEY = process.env.ANTHROPIC_API_KEY",
210
+ '{"token": "the bearer token to use"}',
211
+ "--token <value> the API token",
212
+ ):
213
+ with self.subTest(line):
214
+ self.assertEqual(redact(line), line)
215
+
216
+ def test_inert_gate_does_not_smuggle_a_real_value_through(self):
217
+ """#3982 review, MAJOR 7 — the gate must require the WHOLE value.
218
+
219
+ Five of the inert patterns used to be un-anchored or to accept an
220
+ arbitrary payload, so a labelled slot whose value merely CARRIED
221
+ an inert-looking wrapper was skipped entirely and stored verbatim
222
+ — a regression against coverage already shipped on ``main``. The
223
+ bare cases below are the easy half; the wrapped ones are the
224
+ property that actually broke.
225
+ """
226
+ token = "zQ7x" + "Vb2n" + "Kd9w" + "Rt4y"
227
+ hexish = "a8f3c1d2" + "e4b50f6a" + "9c7d3e81" + "b2f45a6c" + "d90e17bb"
228
+ # A SINGLE-character-class run clears no entropy gate, but ``main``
229
+ # masked it and a 16-letter passphrase is a real credential.
230
+ flat = "abcdefgh" + "ijklmnop"
231
+ wrappers = (
232
+ lambda v: v, # bare
233
+ lambda v: "<" + v + ">",
234
+ lambda v: "{{" + v + "}}",
235
+ lambda v: "vault:pg/password" + v,
236
+ lambda v: "vault:pg/" + v,
237
+ lambda v: "[REDACTED]" + v,
238
+ lambda v: "[REDACTED:env_key_value]" + v,
239
+ lambda v: "process.env." + v,
240
+ lambda v: "changeme-" + v,
241
+ lambda v: "todo_" + v,
242
+ lambda v: "${" + v + "}",
243
+ lambda v: "%" + v + "%",
244
+ )
245
+ slots = (
246
+ "POSTGRES_PASSWORD: {}",
247
+ "ANTHROPIC_API_KEY={}",
248
+ '{{"token": "{}"}}',
249
+ "--token {}",
250
+ )
251
+ for secret in (token, hexish, flat):
252
+ for wrap in wrappers:
253
+ for slot in slots:
254
+ line = slot.format(wrap(secret))
255
+ with self.subTest(line=line):
256
+ self.assertNotIn(secret, redact(line))
257
+
258
+ def test_inert_values_are_still_skipped(self):
259
+ """#3982 review, MAJOR 5 must stay closed.
260
+
261
+ The MAJOR 7 anchoring above tightens the same list, so the
262
+ false-positive relief it was added for is asserted here in the
263
+ opposite direction: every value below is documentation, and
264
+ redacting it destroys information while protecting nothing.
265
+ """
266
+ for value in (
267
+ "vault:pg/password",
268
+ "vault:anthropic/api_key",
269
+ "[REDACTED]",
270
+ "[REDACTED:memorable_password]",
271
+ "process.env.ANTHROPIC_API_KEY",
272
+ 'os.environ["ANTHROPIC_API_KEY"]',
273
+ "import.meta.env.VITE_API_KEY",
274
+ "<your-password-here>",
275
+ "<generate-with-openssl-rand>",
276
+ "{{ db_password }}",
277
+ "${DB_PASSWORD}",
278
+ "%API_TOKEN%",
279
+ "changeme-in-production",
280
+ "todo-before-launch",
281
+ "****",
282
+ "the bearer token to use",
283
+ ):
284
+ with self.subTest(value):
285
+ self.assertTrue(is_inert_value(value), value)
286
+
287
+ def test_inert_anchors_do_not_match_past_a_trailing_newline(self):
288
+ """Python ``$`` matches before a final newline; JavaScript ``$``
289
+ (without ``/m``) does not. Every anchored entry in the inert list
290
+ is spelled with ``\\Z`` so the two engines cannot fork here."""
291
+ for value in ("<value>\n", "{{token}}\n", "${DB_PASSWORD}\n"):
292
+ with self.subTest(value):
293
+ self.assertFalse(is_inert_value(value), repr(value))
294
+
295
+ def test_authorization_headers_are_masked_case_insensitively(self):
296
+ """#3982 review, MAJOR 6 — HTTP/2 lowercases header names."""
297
+ token = "AAAABBBB" + "CCCCDDDD" + "EEEEFFFF"
298
+ for line in (
299
+ "Authorization: Bearer " + token,
300
+ "authorization: bearer " + token,
301
+ "AUTHORIZATION: BEARER " + token,
302
+ ):
303
+ with self.subTest(line):
304
+ out = redact(line)
305
+ self.assertNotIn(token, out)
306
+ self.assertIn("[REDACTED:bearer_auth_header]", out)
307
+ creds = "YWxpY2U6" + "c3VwZXJzZWNyZXQ="
308
+ for line in (
309
+ "Authorization: Basic " + creds,
310
+ "authorization: basic " + creds,
311
+ ):
312
+ with self.subTest(line):
313
+ out = redact(line)
314
+ self.assertNotIn(creds, out)
315
+ self.assertIn("[REDACTED:basic_auth_header]", out)
316
+
317
+ def test_token_adjacent_to_non_ascii_text_is_masked(self):
318
+ """#3982 review, BLOCKER 1 — the Unicode word-boundary fork.
319
+
320
+ Python ``re`` treats ``\\b`` as Unicode-aware for ``str``
321
+ patterns; JavaScript ``RegExp`` without ``/u`` treats it as
322
+ ASCII-only. Every generated rule is ``\\b``-anchored, so a token
323
+ abutting CJK / Cyrillic / accented-Latin text was masked by the
324
+ TypeScript engine and stored VERBATIM by this one. CJK has no
325
+ word spacing, so this is the ordinary case.
326
+ """
327
+ token = "sk-" + "ant-" + "api03-" + ("A" * 40)
328
+ for neighbour in ("К", "中", "é", "ก", "あ"):
329
+ with self.subTest(neighbour):
330
+ out = redact("note" + neighbour + token + neighbour + "end")
331
+ self.assertNotIn(token, out)
332
+ self.assertIn("[REDACTED", out)
333
+
334
+ def test_unicode_whitespace_separator_still_masks(self):
335
+ """The other half of the semantics bridge.
336
+
337
+ JS ``\\s`` is Unicode-aware even without ``/u``, so a bare
338
+ ``re.ASCII`` port would narrow Python's ``\\s`` BELOW the JS one
339
+ and re-open the fork on the separator instead of the boundary.
340
+ """
341
+ token = "zQ7x" + "Vb2n" + "Kd9w" + "Rt4y"
342
+ for space in (" ", "\u3000", "\u00a0", "\ufeff"):
343
+ with self.subTest(repr(space)):
344
+ out = redact("API_TOKEN:" + space + token)
345
+ self.assertNotIn(token, out)
346
+ self.assertIn("[REDACTED", out)
347
+
348
+ def test_prefixed_provider_token_is_removed(self):
349
+ token = "sk-" + "ant-" + "oat01-" + ("A" * 60)
350
+ out = redact("here is the token " + token)
351
+ self.assertNotIn(token, out)
352
+ self.assertIn(REDACTED_MARKER.rstrip("]"), out)
353
+
354
+ def test_redaction_is_stable_under_repetition(self):
355
+ text = "db " + _pg_uri("Pineapple" + "Roof8") + " pw: " + "Sparrow" + "Kettle31"
356
+ once = redact(text)
357
+ self.assertEqual(redact(once), once)
358
+
359
+ def test_none_and_empty_pass_through(self):
360
+ self.assertIsNone(redact(None))
361
+ self.assertEqual(redact(""), "")
362
+
363
+ def test_metadata_strings_are_redacted_recursively(self):
364
+ password = "Mango" + "TreeHouse77"
365
+ meta = {
366
+ "source": "claude-code",
367
+ "nested": {"note": "password: " + password},
368
+ "list": ["password: " + password],
369
+ "count": 3,
370
+ }
371
+ out = redact_metadata(meta)
372
+ self.assertNotIn(password, json.dumps(out))
373
+ self.assertEqual(out["source"], "claude-code")
374
+ self.assertEqual(out["count"], 3)
375
+
376
+
377
+ class _FakeResponse:
378
+ def __init__(self):
379
+ self.status = 200
380
+
381
+ def read(self):
382
+ return b"{}"
383
+
384
+ def __enter__(self):
385
+ return self
386
+
387
+ def __exit__(self, *exc):
388
+ return False
389
+
390
+
391
+ class WireCaptureCase(unittest.TestCase):
392
+ """Base case that captures the REQUEST BODY BYTES the client sends.
393
+
394
+ Nothing in `HindsightClient` is stubbed — only `urlopen` — so these
395
+ assertions run through the production `retain()` / `_request()` path
396
+ and prove what actually leaves the process.
397
+ """
398
+
399
+ def setUp(self):
400
+ self.sent = []
401
+ self._real_urlopen = urllib.request.urlopen
402
+
403
+ def fake_urlopen(req, timeout=None):
404
+ self.sent.append(
405
+ {
406
+ "url": req.full_url,
407
+ "method": req.get_method(),
408
+ "body": req.data.decode("utf-8") if req.data else "",
409
+ }
410
+ )
411
+ return _FakeResponse()
412
+
413
+ urllib.request.urlopen = fake_urlopen
414
+ self.addCleanup(setattr, urllib.request, "urlopen", self._real_urlopen)
415
+ self.client = HindsightClient("http://localhost:1")
416
+
417
+ def wire(self):
418
+ return json.dumps([s["body"] for s in self.sent])
419
+
420
+ def stored_text(self):
421
+ """Every item's content, in send order, concatenated.
422
+
423
+ A split retain stores adjacent rows in ONE bank, so what an
424
+ attacker (or a future recall) can reassemble is the concatenation
425
+ — not any single chunk. Assertions about a boundary-straddling
426
+ credential have to be made against this, because a cut through the
427
+ middle of the secret makes per-chunk checks pass by accident.
428
+ """
429
+ out = []
430
+ for sent in self.sent:
431
+ body = json.loads(sent["body"])
432
+ for item in body.get("items", []):
433
+ out.append(item.get("content") or "")
434
+ return "".join(out)
435
+
436
+
437
+ class RetainWirePayloadTests(WireCaptureCase):
438
+ """The bytes that actually leave the process carry no credential."""
439
+
440
+ def test_retain_content_is_redacted_on_the_wire(self):
441
+ password = "Hunter" + "Two99"
442
+ self.client.retain(bank_id="bank", content="DSN " + _pg_uri(password))
443
+ self.assertEqual(len(self.sent), 1)
444
+ self.assertTrue(self.sent[0]["url"].endswith("/memories"))
445
+ self.assertNotIn(password, self.wire())
446
+ self.assertIn("[REDACTED:db_uri_password]", self.wire())
447
+
448
+ def test_retain_context_and_metadata_are_redacted_on_the_wire(self):
449
+ password = "Fluffy" + "Barnaby" + "1998"
450
+ self.client.retain(
451
+ bank_id="bank",
452
+ content="a normal sentence",
453
+ context="password: " + password,
454
+ metadata={"detail": "password: " + password},
455
+ )
456
+ self.assertNotIn(password, self.wire())
457
+
458
+ def test_secret_straddling_a_split_boundary_is_still_redacted(self):
459
+ """Redaction runs BEFORE splitting, so a credential that would be
460
+ cut in half by `split_retain_content` is masked while whole.
461
+
462
+ Asserted against the REASSEMBLED text, and on the presence of the
463
+ marker — not merely on the absence of the password. A chunk
464
+ boundary through the middle of the credential makes "password not
465
+ in any single chunk" true by accident, so that check alone would
466
+ pass with the redactor removed entirely while both halves sat in
467
+ adjacent rows of the same bank.
468
+ """
469
+ from lib.retain_split import retain_content_limit
470
+
471
+ password = "Hunter" + "Two99"
472
+ uri = _pg_uri(password)
473
+ # Land the URI so that it spans the first chunk boundary.
474
+ limit = retain_content_limit()
475
+ prefix = "x " * ((limit - (len(uri) // 2)) // 2)
476
+ self.client.retain(bank_id="bank", content=prefix + uri + " tail")
477
+ self.assertGreater(len(self.sent), 1, "expected a split retain")
478
+ stored = self.stored_text()
479
+ self.assertIn("[REDACTED:db_uri_password]", stored)
480
+ self.assertNotIn(password, stored)
481
+ # No fragment of the credential survives on either side of the cut.
482
+ for size in range(4, len(password) + 1):
483
+ self.assertNotIn(password[:size], stored)
484
+ self.assertNotIn(password[-size:], stored)
485
+
486
+ def test_request_backstop_redacts_a_body_that_skipped_retain(self):
487
+ """A caller reaching the HTTP leg without going through retain()
488
+ still cannot write a raw credential."""
489
+ password = "Sparrow" + "Kettle31"
490
+ self.client._request(
491
+ "POST",
492
+ "/v1/default/banks/bank/memories",
493
+ {"items": [{"content": "password: " + password}], "async": True},
494
+ )
495
+ self.assertNotIn(password, self.wire())
496
+ self.assertIn("[REDACTED", self.wire())
497
+
498
+ def test_recall_body_is_not_rewritten(self):
499
+ """`POST .../memories/recall` is a READ. Rewriting the query would
500
+ silently change search results."""
501
+ query = "what is the postgres://u:PineappleRoof8@h/d dsn"
502
+ self.client.recall(bank_id="bank", query=query)
503
+ self.assertIn(json.dumps(query)[1:-1], self.sent[0]["body"])
504
+
505
+ def test_is_memory_write_classification(self):
506
+ cases = [
507
+ ("POST", "/v1/default/banks/b/memories", True),
508
+ ("POST", "/v1/default/banks/b/memories/", True),
509
+ ("POST", "/v1/default/banks/b/memories/recall", False),
510
+ ("GET", "/v1/default/banks/b/memories", False),
511
+ ("PATCH", "/v1/default/banks/b/config", False),
512
+ ("POST", "/v1/default/banks/b/memories?dry=1", True),
513
+ ]
514
+ for method, path, expected in cases:
515
+ with self.subTest(path=path, method=method):
516
+ self.assertEqual(
517
+ HindsightClient._is_memory_write(method, path), expected
518
+ )
519
+
520
+
521
+ if __name__ == "__main__":
522
+ unittest.main()