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.
- package/dist/cli/skill-validate-pretool.mjs +15 -2
- package/dist/cli/switchroom.js +678 -444
- package/dist/host-control/main.js +149 -3
- package/package.json +4 -2
- package/profiles/_base/start.sh.hbs +37 -12
- package/telegram-plugin/bridge/bridge.ts +4 -4
- package/telegram-plugin/dist/bridge/bridge.js +4 -4
- package/telegram-plugin/dist/gateway/gateway.js +317 -95
- package/telegram-plugin/dist/server.js +4 -4
- package/telegram-plugin/format.ts +100 -29
- package/telegram-plugin/gateway/gateway.ts +22 -29
- package/telegram-plugin/gateway/ipc-server.ts +18 -15
- package/telegram-plugin/gateway/model-command.ts +34 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +12 -1
- package/telegram-plugin/operator-events.ts +40 -16
- package/telegram-plugin/render/unsupported-token-guard.ts +10 -6
- package/telegram-plugin/secret-detect/db-uri.ts +90 -0
- package/telegram-plugin/secret-detect/index.ts +24 -1
- package/telegram-plugin/secret-detect/inert-values.ts +147 -0
- package/telegram-plugin/secret-detect/kv-scanner.ts +108 -0
- package/telegram-plugin/secret-detect/patterns.ts +24 -4
- package/telegram-plugin/tests/format-consistency.test.ts +111 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +40 -2
- package/telegram-plugin/tests/ipc-server-validate-operator.test.ts +20 -11
- package/telegram-plugin/tests/mcp-instructions-budget.test.ts +8 -1
- package/telegram-plugin/tests/outbound-send-path.test.ts +1 -1
- package/telegram-plugin/tests/render/unsupported-token-guard.test.ts +36 -5
- package/telegram-plugin/tests/secret-detect-cross-engine.test.ts +263 -0
- package/telegram-plugin/tests/secret-detect-write-path.test.ts +403 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +2 -2
- package/vendor/hindsight-memory/scripts/lib/client.py +58 -0
- package/vendor/hindsight-memory/scripts/lib/secret_patterns.json +431 -0
- package/vendor/hindsight-memory/scripts/lib/secret_redact.py +563 -0
- package/vendor/hindsight-memory/scripts/lib/secret_redaction_vectors.json +397 -0
- 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()
|