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,403 @@
1
+ /**
2
+ * Two engines, one behaviour — the TypeScript half.
3
+ *
4
+ * Agent memory is written from BOTH languages: the vendored Hindsight plugin
5
+ * retains from Python (`vendor/hindsight-memory/scripts/lib/client.py`), while
6
+ * the MCP shim, the handoff mirror and the profile CLI write from TypeScript.
7
+ * If the two redactors drift, a credential blocked on one path sails through
8
+ * the other, and nothing goes red — the fork is silent by construction.
9
+ *
10
+ * Two mechanisms stop that, and this file is half of the second:
11
+ *
12
+ * 1. ONE pattern table. `vendor/hindsight-memory/scripts/lib/secret_patterns.json`
13
+ * is GENERATED from `telegram-plugin/secret-detect/patterns.ts` and
14
+ * byte-compared by `scripts/check-secret-pattern-parity.ts` in `bun lint`.
15
+ * 2. ONE behaviour contract. `lib/secret_redaction_vectors.json` is asserted
16
+ * here AND by `vendor/hindsight-memory/scripts/tests/test_secret_redact.py`.
17
+ * The imperative gates (entropy floors, placeholder suppression, the
18
+ * memorable-password character-class rule) are hand-ported and cannot be
19
+ * generated, so they are pinned by shared expected OUTPUT instead.
20
+ *
21
+ * Also covers the two detection gaps this write-path work closed: connection-
22
+ * string passwords and human-memorable passwords.
23
+ *
24
+ * Every credential-shaped literal here is synthetic.
25
+ */
26
+ import { describe, it, expect } from "vitest";
27
+ import { readFileSync } from "node:fs";
28
+ import { dirname, join } from "node:path";
29
+ import { fileURLToPath } from "node:url";
30
+
31
+ import { redact } from "../secret-detect/redact.js";
32
+ import { isInertValue } from "../secret-detect/inert-values.js";
33
+ import { scanDbUris, DB_URI_RULE_ID } from "../secret-detect/db-uri.js";
34
+ import {
35
+ looksLikeMemorablePassword,
36
+ scanMemorablePasswords,
37
+ } from "../secret-detect/kv-scanner.js";
38
+
39
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
40
+ const VECTORS_PATH = join(
41
+ repoRoot,
42
+ "vendor/hindsight-memory/scripts/lib/secret_redaction_vectors.json",
43
+ );
44
+
45
+ type Vector = { name: string; input: string; expected: string };
46
+ const vectors = (
47
+ JSON.parse(readFileSync(VECTORS_PATH, "utf-8")) as { vectors: Vector[] }
48
+ ).vectors;
49
+
50
+ describe("shared TS/Python redaction vectors", () => {
51
+ it("carries a non-trivial contract", () => {
52
+ // A shrinking vector file would silently weaken the cross-language
53
+ // guarantee; the Python suite asserts the same floor.
54
+ expect(vectors.length).toBeGreaterThanOrEqual(50);
55
+ });
56
+
57
+ it("covers the GENERATED pattern table, not just the imperative gates", () => {
58
+ // #3982 review, MAJOR 4: every vector originally shipped exercised
59
+ // only `db_uri_password`, `memorable_password` and `redact_urls` —
60
+ // the two hand-ported rules plus the URL pass. Nothing touched any of
61
+ // the ~60 GENERATED patterns, which is precisely why a Unicode
62
+ // word-boundary fork inside them shipped past a green CI.
63
+ for (const marker of [
64
+ "[REDACTED:anthropic_api_key]",
65
+ "[REDACTED:jwt]",
66
+ "[REDACTED:aws_access_key]",
67
+ "[REDACTED:pem_private_key]",
68
+ "[REDACTED:bearer_auth_header]",
69
+ "[REDACTED:basic_auth_header]",
70
+ ]) {
71
+ expect(
72
+ vectors.some((v) => v.expected.includes(marker)),
73
+ `no vector pins ${marker}`,
74
+ ).toBe(true);
75
+ }
76
+ // ...and several must sit ADJACENT to non-ASCII text, which is where
77
+ // the two regex engines actually disagreed.
78
+ const nonAsciiPositives = vectors.filter(
79
+ (v) =>
80
+ /[^\u0000-\u007f]/.test(v.input) && v.expected.includes("[REDACTED"),
81
+ );
82
+ expect(nonAsciiPositives.length).toBeGreaterThanOrEqual(4);
83
+ });
84
+
85
+ for (const vector of vectors) {
86
+ it(vector.name, () => {
87
+ expect(redact(vector.input)).toBe(vector.expected);
88
+ });
89
+ }
90
+ });
91
+
92
+ describe("connection-string credentials (gap 1)", () => {
93
+ it("masks the password of a non-http scheme URI", () => {
94
+ // url-redact.ts only ever matched http/https/ws/wss/ftp, so a
95
+ // `postgres://` DSN — the single most common way a production password
96
+ // reaches a chat log — was stored verbatim.
97
+ const password = "Hunter" + "Two99";
98
+ const out = redact("dsn postgres://appuser:" + password + "@db.internal:5432/prod");
99
+ expect(out).not.toContain(password);
100
+ expect(out).toContain(`[REDACTED:${DB_URI_RULE_ID}]`);
101
+ // Everything diagnostically useful survives.
102
+ expect(out).toContain("appuser");
103
+ expect(out).toContain("db.internal:5432/prod");
104
+ });
105
+
106
+ it("ignores a URI with no credentials", () => {
107
+ expect(scanDbUris("postgres://db.internal:5432/prod")).toEqual([]);
108
+ });
109
+
110
+ it("is idempotent over an already-masked DSN", () => {
111
+ const once = redact("postgres://u:" + "Pineapple" + "Roof8" + "@h/d");
112
+ expect(redact(once)).toBe(once);
113
+ });
114
+
115
+ it("masks a password containing an unencoded @ in FULL", () => {
116
+ // #3982 review, MAJOR 3. The original pattern excluded `@` from the
117
+ // password class so the match stopped at the FIRST `@`, which stored
118
+ // 9 of 11 password bytes in clear AND leaked the length of the
119
+ // masked prefix. Unencoded `@` in a pasted DSN is common.
120
+ const password = "p" + "@" + "ssW0rd123";
121
+ const out = redact("postgres://appuser:" + password + "@db.internal:5432/prod");
122
+ expect(out).toBe(
123
+ "postgres://appuser:[REDACTED:db_uri_password]@db.internal:5432/prod",
124
+ );
125
+ // No 4+ byte fragment of the password survives — the shipped code
126
+ // stored `ssW0rd123`, which this catches. (Shorter fragments would
127
+ // collide with the surviving port/host bytes.)
128
+ for (let i = 0; i + 4 <= password.length; i++) {
129
+ expect(out).not.toContain(password.slice(i, i + 4));
130
+ }
131
+ });
132
+
133
+ it("stops at the authority terminator, not at the next @ on the line", () => {
134
+ const out = redact(
135
+ "mysql://root:" + "t0psecretpw" + "@db.internal/app then mail ops@example.com",
136
+ );
137
+ expect(out).toBe(
138
+ "mysql://root:[REDACTED:db_uri_password]@db.internal/app then mail ops@example.com",
139
+ );
140
+ });
141
+
142
+ it("masks each DSN separately in a comma-joined list", () => {
143
+ // Adversarial case for the MAJOR-3 fix: the password class now
144
+ // INCLUDES `@`, so greedy matching backs off to the LAST `@`. The
145
+ // hazard that introduces is one match swallowing two DSNs and hiding
146
+ // the first host. It does not — `/` terminates the authority, so the
147
+ // first URI's path ends the first match.
148
+ const a = "Ab" + "3xQ" + "9zK";
149
+ const b = "Zt" + "7wM" + "2vR";
150
+ const out = redact(
151
+ "postgres://u1:" + a + "@h1.internal:5432/a,postgres://u2:" + b + "@h2.internal:5432/b",
152
+ );
153
+ expect(out).toBe(
154
+ "postgres://u1:[REDACTED:db_uri_password]@h1.internal:5432/a," +
155
+ "postgres://u2:[REDACTED:db_uri_password]@h2.internal:5432/b",
156
+ );
157
+ });
158
+
159
+ it("does not run a password past a query or fragment delimiter", () => {
160
+ const pw = "Ab" + "3xQ" + "9zK";
161
+ expect(redact("mysql://u:" + pw + "@host/db?opt=x@y")).toBe(
162
+ "mysql://u:[REDACTED:db_uri_password]@host/db?opt=x@y",
163
+ );
164
+ expect(redact("redis://u:" + pw + "@host#frag@z")).toBe(
165
+ "redis://u:[REDACTED:db_uri_password]@host#frag@z",
166
+ );
167
+ });
168
+ });
169
+
170
+ describe("human-memorable passwords (gap 2)", () => {
171
+ it("masks a family-name style password behind a password label", () => {
172
+ // The kv-scanner's 4.0-bits entropy floor is tuned for random tokens; a
173
+ // memorable password scores well under it, so `password: <words>` was
174
+ // never a hit.
175
+ const password = "Fluffy" + "Barnaby" + "1998";
176
+ const out = redact("wifi password: " + password);
177
+ expect(out).not.toContain(password);
178
+ expect(out).toContain("[REDACTED:memorable_password]");
179
+ });
180
+
181
+ it("accepts the ` is ` phrasing and prefixed identifiers", () => {
182
+ expect(scanMemorablePasswords("the password is Mango" + "TreeHouse77")).toHaveLength(1);
183
+ expect(scanMemorablePasswords("db_password=Rutherford" + "2024x")).toHaveLength(1);
184
+ });
185
+
186
+ it("does NOT fire on prose or placeholders", () => {
187
+ // False positives here corrupt stored conversation irreversibly, so the
188
+ // rule requires >= 2 character classes: single-class English words are out.
189
+ for (const prose of [
190
+ "The password policy requires rotation every 90 days.",
191
+ "the password is rotated quarterly",
192
+ "password: ${DB_PASSWORD}",
193
+ "password=<your-password-here>",
194
+ "password: vault:postgres/app_password",
195
+ "password: [REDACTED:memorable_password]",
196
+ ]) {
197
+ expect(redact(prose)).toBe(prose);
198
+ }
199
+ });
200
+
201
+ it("does NOT fire on prose that ENDS the sentence", () => {
202
+ // #3982 review, BLOCKER 2. The value class is `[^\s"']`, so the
203
+ // sentence terminator was captured INTO the value and the `.` itself
204
+ // supplied the second character class the gate asks for. Every string
205
+ // below redacted as a credential on the shipped code — and
206
+ // sentence-final is the most common position for these words, so the
207
+ // suite's original "mid-sentence" cases were tests chosen to pass.
208
+ //
209
+ // The cost was not just noise: "Ken confirmed the password is
210
+ // unchanged." stored as "…the password is [REDACTED:...]", which
211
+ // INVERTS the meaning for every later recall.
212
+ for (const prose of [
213
+ "The password is required.",
214
+ "The password is incorrect.",
215
+ "The password is unchanged.",
216
+ "The password is different.",
217
+ "The password is protected.",
218
+ "Ken confirmed the password is unchanged.",
219
+ "password: required, minimum twelve characters, mixed case",
220
+ "The passphrase is unchanged; rotate it next quarter.",
221
+ ]) {
222
+ expect(redact(prose)).toBe(prose);
223
+ }
224
+ });
225
+
226
+ it("still masks a real password that ends a sentence, punctuation included", () => {
227
+ const password = "Mango" + "TreeHouse77";
228
+ const out = redact("The wifi password is " + password + ".");
229
+ expect(out).toBe("The wifi password is [REDACTED:memorable_password]");
230
+ expect(out).not.toContain(password);
231
+ });
232
+
233
+ it("does not leak the last byte of a password that ends in punctuation", () => {
234
+ // Masking only the punctuation-stripped core would leave a real
235
+ // trailing `!` in clear.
236
+ const password = "Fluffy" + "Barnaby" + "1998!";
237
+ const out = redact("password: " + password);
238
+ expect(out).toBe("password: [REDACTED:memorable_password]");
239
+ });
240
+
241
+ it("classifies candidate values by character-class breadth", () => {
242
+ expect(looksLikeMemorablePassword("Fluffy" + "Barnaby" + "1998")).toBe(true);
243
+ expect(looksLikeMemorablePassword("rotated")).toBe(false); // too short + 1 class
244
+ expect(looksLikeMemorablePassword("quarterly")).toBe(false); // 1 class
245
+ expect(looksLikeMemorablePassword("required.")).toBe(false); // punctuation != a class
246
+ expect(looksLikeMemorablePassword("unchanged.")).toBe(false);
247
+ expect(looksLikeMemorablePassword("incorrect,")).toBe(false);
248
+ expect(looksLikeMemorablePassword("aaaaaaaaaa1A")).toBe(false); // <4 distinct chars
249
+ expect(looksLikeMemorablePassword("${SOME_VAR}")).toBe(false);
250
+ });
251
+ });
252
+
253
+ describe("inert values are not credentials (MAJOR 5)", () => {
254
+ // The inert-value list existed before #3982's review but applied ONLY
255
+ // to the new memorable_password rule, so LETTER CASE decided whether a
256
+ // vault reference survived: the ALL-CAPS spelling matched
257
+ // `env_key_value` and was destroyed, the lowercase one matched
258
+ // `memorable_password` and survived. A vault key NAME is exactly what
259
+ // switchroom wants an agent to remember, and `src/cli/memory.ts`
260
+ // prints a "credential-shaped text was replaced" warning each time —
261
+ // so the false positives were also training operators to ignore it.
262
+ it("preserves placeholders and references regardless of case", () => {
263
+ for (const line of [
264
+ "POSTGRES_PASSWORD: vault:pg/password",
265
+ "postgres_password: vault:pg/password",
266
+ "ANTHROPIC_API_KEY: vault:anthropic/api_key",
267
+ "PASSWORD: ${DB_PASSWORD}",
268
+ "password: ${DB_PASSWORD}",
269
+ "API_TOKEN=%API_TOKEN%",
270
+ "JWT_SECRET=<generate-with-openssl-rand>",
271
+ "DB_PASSWORD=changeme-in-production",
272
+ "const API_KEY = process.env.ANTHROPIC_API_KEY",
273
+ '{"token": "the bearer token to use"}',
274
+ "--token <value> the API token",
275
+ ]) {
276
+ expect(redact(line)).toBe(line);
277
+ }
278
+ });
279
+
280
+ it("still masks a REAL value in the same slots", () => {
281
+ // The gate must not become a way to smuggle a credential through.
282
+ const token = "zQ7x" + "Vb2n" + "Kd9w" + "Rt4y";
283
+ for (const line of [
284
+ "POSTGRES_PASSWORD: " + token,
285
+ "ANTHROPIC_API_KEY=" + token,
286
+ '{"token": "' + token + '"}',
287
+ "--token " + token,
288
+ ]) {
289
+ expect(redact(line)).not.toContain(token);
290
+ }
291
+ });
292
+
293
+ // #3982 review, MAJOR 7. Five of the inert patterns were either not
294
+ // end-anchored (`/^vault:/`, `/^\[REDACTED/`, the `process.env` rule)
295
+ // or accepted any payload (`/^<[^>]*>$/`, `/^\{\{…\}\}$/`), so a value
296
+ // that merely CARRIED an inert-looking wrapper skipped the three gated
297
+ // rules and was stored verbatim — a regression against coverage
298
+ // already shipped on main, in the very mechanism added to harden it.
299
+ const WRAPPERS: Array<(v: string) => string> = [
300
+ (v) => v,
301
+ (v) => `<${v}>`,
302
+ (v) => `{{${v}}}`,
303
+ (v) => `vault:pg/password${v}`,
304
+ (v) => `vault:pg/${v}`,
305
+ (v) => `[REDACTED]${v}`,
306
+ (v) => `[REDACTED:env_key_value]${v}`,
307
+ (v) => `process.env.${v}`,
308
+ (v) => `changeme-${v}`,
309
+ (v) => `todo_${v}`,
310
+ (v) => `\${${v}}`,
311
+ (v) => `%${v}%`,
312
+ ];
313
+ const SLOTS: Array<(v: string) => string> = [
314
+ (v) => `POSTGRES_PASSWORD: ${v}`,
315
+ (v) => `ANTHROPIC_API_KEY=${v}`,
316
+ (v) => `{"token": "${v}"}`,
317
+ (v) => `--token ${v}`,
318
+ ];
319
+
320
+ it("does not let an inert WRAPPER smuggle a real value through", () => {
321
+ const token = "zQ7x" + "Vb2n" + "Kd9w" + "Rt4y";
322
+ const hexish =
323
+ "a8f3c1d2" + "e4b50f6a" + "9c7d3e81" + "b2f45a6c" + "d90e17bb";
324
+ // A SINGLE-character-class run is the other half: it clears no
325
+ // entropy gate, but `main` masked it and a 16-letter passphrase is a
326
+ // real credential, so the run floor has to catch it too.
327
+ const flat = "abcdefgh" + "ijklmnop";
328
+ for (const secret of [token, hexish, flat]) {
329
+ for (const wrap of WRAPPERS) {
330
+ for (const slot of SLOTS) {
331
+ const line = slot(wrap(secret));
332
+ expect(redact(line), line).not.toContain(secret);
333
+ }
334
+ }
335
+ }
336
+ });
337
+
338
+ it("keeps skipping values that are inert END TO END", () => {
339
+ // The other direction of MAJOR 7: anchoring must not re-open the
340
+ // false positives MAJOR 5 closed.
341
+ for (const value of [
342
+ "vault:pg/password",
343
+ "vault:anthropic/api_key",
344
+ "[REDACTED]",
345
+ "[REDACTED:memorable_password]",
346
+ "process.env.ANTHROPIC_API_KEY",
347
+ 'os.environ["ANTHROPIC_API_KEY"]',
348
+ "import.meta.env.VITE_API_KEY",
349
+ "<your-password-here>",
350
+ "<generate-with-openssl-rand>",
351
+ "{{ db_password }}",
352
+ "${DB_PASSWORD}",
353
+ "%API_TOKEN%",
354
+ "changeme-in-production",
355
+ "todo-before-launch",
356
+ "****",
357
+ "the bearer token to use",
358
+ ]) {
359
+ expect(isInertValue(value), value).toBe(true);
360
+ }
361
+ });
362
+
363
+ it("does not treat a trailing newline as end-of-value", () => {
364
+ // Python `$` also matches BEFORE a final newline, JavaScript `$`
365
+ // (no /m) does not; the Python mirror spells every anchor `\Z` so
366
+ // the engines cannot fork here. Asserted on both sides.
367
+ for (const value of ["<value>\n", "{{token}}\n", "${DB_PASSWORD}\n"]) {
368
+ expect(isInertValue(value), JSON.stringify(value)).toBe(false);
369
+ }
370
+ });
371
+ });
372
+
373
+ describe("authorization headers (MAJOR 6)", () => {
374
+ it("masks a lowercased bearer header (HTTP/2 wire form)", () => {
375
+ const token = "AAAABBBB" + "CCCCDDDD" + "EEEEFFFF";
376
+ for (const line of [
377
+ "Authorization: Bearer " + token,
378
+ "authorization: bearer " + token,
379
+ "AUTHORIZATION: BEARER " + token,
380
+ ]) {
381
+ const out = redact(line);
382
+ expect(out).not.toContain(token);
383
+ expect(out).toContain("[REDACTED:bearer_auth_header]");
384
+ }
385
+ });
386
+
387
+ it("masks a Basic header — base64 is an encoding, not a cipher", () => {
388
+ const creds = "YWxpY2U6" + "c3VwZXJzZWNyZXQ=";
389
+ for (const line of [
390
+ "Authorization: Basic " + creds,
391
+ "authorization: basic " + creds,
392
+ ]) {
393
+ const out = redact(line);
394
+ expect(out).not.toContain(creds);
395
+ expect(out).toContain("[REDACTED:basic_auth_header]");
396
+ }
397
+ });
398
+
399
+ it("does not fire on the WORD bearer in prose", () => {
400
+ const prose = "ask the bearer of the token to rotate it";
401
+ expect(redact(prose)).toBe(prose);
402
+ });
403
+ });
@@ -294,7 +294,7 @@ describe('#2798 turn-flush punctuation/bold parity with reply', () => {
294
294
 
295
295
  const start = moduleSrc.indexOf('export function normalizeOutboundBody(')
296
296
  const redactIdx = moduleSrc.indexOf('redact(text, site)', start)
297
- const normIdx = moduleSrc.indexOf('stripExcessBold(normalizePunctuation(text))', start)
297
+ const normIdx = moduleSrc.indexOf('stripExcessBold(normalizePunctuation(text),', start)
298
298
  const scrubIdx = moduleSrc.indexOf('scrubVoice(text)', start)
299
299
  expect(start).toBeGreaterThan(0)
300
300
  expect(redactIdx).toBeGreaterThan(start)
@@ -322,7 +322,7 @@ describe('#2798 turn-flush punctuation/bold parity with reply', () => {
322
322
  // Parity, structurally: after consolidation the reply and turn_flush sites
323
323
  // share ONE normalization implementation — the punctuation/bold wrapper
324
324
  // lives only inside normalizeOutboundBody, and both sites invoke it.
325
- expect(replyModuleSrc).toContain('stripExcessBold(normalizePunctuation(text))')
325
+ expect(replyModuleSrc).toContain('stripExcessBold(normalizePunctuation(text),')
326
326
  expect(streamSrc).toContain('normalizeOutboundBody(')
327
327
  expect(streamSrc).toContain(`'turn_flush',`)
328
328
  })
@@ -13,6 +13,7 @@ from pathlib import Path
13
13
  from typing import Optional
14
14
 
15
15
  from .retain_split import part_document_id, part_metadata, split_retain_content
16
+ from .secret_redact import redact, redact_metadata, redact_retain_fields
16
17
 
17
18
  DEFAULT_TIMEOUT = 15 # seconds
18
19
  HEALTH_CHECK_RETRIES = 3
@@ -80,8 +81,50 @@ class HindsightClient:
80
81
  headers["Authorization"] = f"Bearer {self.api_token}"
81
82
  return headers
82
83
 
84
+ @staticmethod
85
+ def _is_memory_write(method: str, path: str) -> bool:
86
+ """True for a request that CREATES memory rows.
87
+
88
+ `POST .../memories` is the write; `POST .../memories/recall` is a
89
+ read that happens to be a POST, and must not be rewritten.
90
+ """
91
+ if method.upper() != "POST":
92
+ return False
93
+ base = path.split("?", 1)[0].rstrip("/")
94
+ return base.endswith("/memories")
95
+
96
+ @staticmethod
97
+ def _redact_write_body(body: Optional[dict]) -> Optional[dict]:
98
+ """Structural backstop — mask secrets in a memory-write body.
99
+
100
+ `retain()` already redacts BEFORE splitting (which is where a
101
+ secret straddling a chunk boundary is still whole). This second
102
+ pass exists so a future caller that reaches `_request` without
103
+ going through `retain()` cannot write a raw credential into a
104
+ bank: every memory row this client creates is emitted from here.
105
+ Redaction is mask-in-place and stable on already-masked text, so
106
+ running it twice is a no-op on the second pass.
107
+ """
108
+ if not isinstance(body, dict):
109
+ return body
110
+ items = body.get("items")
111
+ if not isinstance(items, list):
112
+ return body
113
+ for item in items:
114
+ if not isinstance(item, dict):
115
+ continue
116
+ if isinstance(item.get("content"), str):
117
+ item["content"] = redact(item["content"])
118
+ if isinstance(item.get("context"), str):
119
+ item["context"] = redact(item["context"])
120
+ if isinstance(item.get("metadata"), dict):
121
+ item["metadata"] = redact_metadata(item["metadata"])
122
+ return body
123
+
83
124
  def _request(self, method: str, path: str, body: Optional[dict] = None, timeout: int = DEFAULT_TIMEOUT) -> dict:
84
125
  timeout = self._resolve_timeout(timeout)
126
+ if self._is_memory_write(method, path):
127
+ body = self._redact_write_body(body)
85
128
  url = f"{self.api_url}{path}"
86
129
  data = json.dumps(body).encode() if body else None
87
130
  req = urllib.request.Request(url, data=data, headers=self._headers(), method=method)
@@ -232,6 +275,21 @@ class HindsightClient:
232
275
  ``backfill_transcripts.py`` logs and backs off. The parts already
233
276
  committed are upserted on the next attempt, not duplicated.
234
277
  """
278
+ # ── Secret redaction, BEFORE splitting ────────────────────────
279
+ #
280
+ # Every retain in this plugin (Stop-hook auto-retain, SubagentStop
281
+ # sidechain retain, the pending-backlog drainer, boot
282
+ # reconciliation, transcript backfill) funnels through this one
283
+ # method, so this is the intake chokepoint for memory content —
284
+ # the same property `retain_split` relies on.
285
+ #
286
+ # It runs BEFORE `split_retain_content` on purpose: a credential
287
+ # straddling a 3000-char part boundary is only whole here. The
288
+ # second pass in `_request` is the structural backstop for any
289
+ # caller that never reaches this method.
290
+ content, context, metadata = redact_retain_fields(
291
+ content, context, metadata
292
+ )
235
293
  parts = split_retain_content(content)
236
294
  total = len(parts)
237
295
  response = None