switchroom 0.19.34 → 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 (35) hide show
  1. package/dist/auth-broker/index.js +7 -1
  2. package/dist/cli/skill-validate-pretool.mjs +15 -2
  3. package/dist/cli/switchroom.js +1005 -482
  4. package/dist/host-control/main.js +156 -4
  5. package/dist/vault/approvals/kernel-server.js +7 -1
  6. package/dist/vault/broker/server.js +7 -1
  7. package/package.json +4 -2
  8. package/profiles/_base/start.sh.hbs +37 -12
  9. package/telegram-plugin/dist/gateway/gateway.js +476 -116
  10. package/telegram-plugin/format.ts +70 -15
  11. package/telegram-plugin/gateway/gateway.ts +27 -31
  12. package/telegram-plugin/gateway/ipc-server.ts +18 -15
  13. package/telegram-plugin/gateway/model-command.ts +279 -23
  14. package/telegram-plugin/gateway/outbound-send-path.ts +12 -1
  15. package/telegram-plugin/operator-events.ts +40 -16
  16. package/telegram-plugin/secret-detect/db-uri.ts +90 -0
  17. package/telegram-plugin/secret-detect/index.ts +24 -1
  18. package/telegram-plugin/secret-detect/inert-values.ts +147 -0
  19. package/telegram-plugin/secret-detect/kv-scanner.ts +108 -0
  20. package/telegram-plugin/secret-detect/patterns.ts +24 -4
  21. package/telegram-plugin/tests/format-consistency.test.ts +93 -0
  22. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +40 -2
  23. package/telegram-plugin/tests/ipc-server-validate-operator.test.ts +20 -11
  24. package/telegram-plugin/tests/model-command.test.ts +415 -4
  25. package/telegram-plugin/tests/outbound-send-path.test.ts +1 -1
  26. package/telegram-plugin/tests/secret-detect-cross-engine.test.ts +263 -0
  27. package/telegram-plugin/tests/secret-detect-write-path.test.ts +403 -0
  28. package/telegram-plugin/tests/turn-flush-safety.test.ts +2 -2
  29. package/vendor/hindsight-memory/scripts/lib/client.py +58 -0
  30. package/vendor/hindsight-memory/scripts/lib/secret_patterns.json +431 -0
  31. package/vendor/hindsight-memory/scripts/lib/secret_redact.py +563 -0
  32. package/vendor/hindsight-memory/scripts/lib/secret_redaction_vectors.json +397 -0
  33. package/vendor/hindsight-memory/scripts/subagent_retain.py +103 -7
  34. package/vendor/hindsight-memory/scripts/tests/test_secret_redact.py +522 -0
  35. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +377 -0
@@ -0,0 +1,263 @@
1
+ /**
2
+ * DIFFERENTIAL test — run BOTH redaction engines over the same corpus and
3
+ * compare their output byte for byte.
4
+ *
5
+ * Why this file exists (#3982 adversarial review, BLOCKER 1 + MAJOR 4):
6
+ *
7
+ * The write-path work shipped two anti-fork mechanisms and neither could
8
+ * see the fork that actually existed.
9
+ *
10
+ * 1. `check-secret-pattern-parity` byte-compares the generated table
11
+ * against `patterns.ts`. It proves the pattern SOURCES are equal —
12
+ * and the sources WERE equal. Identical source is not identical
13
+ * behaviour: Python `re` treats `\b` / `\w` / `\d` and `re.I`
14
+ * case-folding as UNICODE-aware for `str` patterns, JavaScript
15
+ * `RegExp` without the `u` flag treats them as ASCII-only. Every
16
+ * generated rule is `\b`-anchored, so a synthetic `sk-ant-`-shaped
17
+ * key written next to Japanese, Chinese, Russian or accented-Latin
18
+ * text was masked by TypeScript and stored VERBATIM by Python.
19
+ * CJK has no word spacing, so "token abutting a non-ASCII letter"
20
+ * is the ordinary case, not an exotic one.
21
+ * 2. `secret_redaction_vectors.json` pins BEHAVIOUR — but every vector
22
+ * it shipped with exercised only the two hand-written imperative
23
+ * rules. Zero vectors touched any of the ~60 GENERATED patterns, so
24
+ * the whole generated table had no behavioural coverage at all.
25
+ *
26
+ * Fixed vectors are still the primary contract (they pin what the output
27
+ * should BE, which a differential test cannot). This file adds the thing
28
+ * neither mechanism had: a machine-generated corpus, wide enough to cover
29
+ * combinations nobody thought to write down, run through both engines
30
+ * with the outputs compared. A future semantic divergence — a new
31
+ * shorthand class, an `re.I` folding difference, a `\s` definition
32
+ * mismatch — fails HERE without anyone having predicted it.
33
+ *
34
+ * The Python half runs in a real `python3` subprocess importing the
35
+ * shipped `lib/secret_redact.py`. That is deliberate: an in-process
36
+ * re-implementation would be a third engine, and a third engine can fork
37
+ * too.
38
+ *
39
+ * Every credential-shaped literal is synthetic, assembled at runtime, and
40
+ * never echoed into an assertion message — divergences are reported by
41
+ * family + neighbour codepoint only.
42
+ */
43
+ import { describe, it, expect } from "vitest";
44
+ import { execFileSync } from "node:child_process";
45
+ import { dirname, join } from "node:path";
46
+ import { fileURLToPath } from "node:url";
47
+
48
+ import { redact } from "../secret-detect/redact.js";
49
+
50
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
51
+ const PY_SCRIPTS_DIR = join(repoRoot, "vendor", "hindsight-memory", "scripts");
52
+
53
+ /**
54
+ * Read a JSON array of strings on stdin, write the redacted array on
55
+ * stdout. Nothing is printed to stdout except the JSON, so a stray
56
+ * warning cannot be mistaken for output.
57
+ */
58
+ const PY_DRIVER = [
59
+ "import json,sys",
60
+ "sys.path.insert(0, sys.argv[1])",
61
+ "from lib.secret_redact import redact",
62
+ "data = json.load(sys.stdin)",
63
+ "sys.stdout.write(json.dumps([redact(s) for s in data]))",
64
+ ].join("\n");
65
+
66
+ function pythonRedactAll(inputs: string[]): string[] {
67
+ const out = execFileSync("python3", ["-c", PY_DRIVER, PY_SCRIPTS_DIR], {
68
+ input: JSON.stringify(inputs),
69
+ encoding: "utf-8",
70
+ maxBuffer: 32 * 1024 * 1024,
71
+ });
72
+ return JSON.parse(out) as string[];
73
+ }
74
+
75
+ // ─── Corpus ───────────────────────────────────────────────────────────
76
+
77
+ /** One synthetic credential per family the generated table claims to cover. */
78
+ const FILL = "AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJ";
79
+ const FAMILIES: Record<string, string> = {
80
+ anthropic_api_key: "sk-" + "ant-" + "api03-" + FILL,
81
+ openai_api_key: "sk-" + FILL + "KKKK",
82
+ github_pat: "ghp_" + FILL.slice(0, 36),
83
+ aws_access_key: "AKIA" + FILL.slice(0, 16),
84
+ google_api_key: "AIza" + FILL.slice(0, 35),
85
+ slack_token: "xoxb-" + "111111111111" + "-" + FILL.slice(0, 24),
86
+ jwt: "eyJ" + "AAAABBBBCCCC" + "." + "DDDDEEEEFFFF" + "." + "GGGGHHHHIIII",
87
+ telegram_bot_token: "1234567" + ":" + FILL.slice(0, 30),
88
+ laravel_sanctum_token: "17|" + FILL,
89
+ env_key_value: "API_TOKEN=" + "zQ7x" + "Vb2n" + "Kd9w" + "Rt4y",
90
+ bearer_header: "Authorization: Bearer " + FILL.slice(0, 24),
91
+ basic_header: "Authorization: Basic " + "YWxpY2U6" + "c3VwZXJzZWNyZXQ=",
92
+ db_uri: "postgres://appuser:" + "LmN0pQrS7t" + "@db.internal:5432/prod",
93
+ memorable_password: "wifi password: " + "Fluffy" + "Barnaby" + "1998",
94
+ };
95
+
96
+ /**
97
+ * Neighbour characters placed immediately before and after the
98
+ * credential. The non-ASCII entries are the whole point: they are where
99
+ * `\b` means different things in the two engines. The whitespace entries
100
+ * cover the OPPOSITE hazard — JS `\s` is Unicode-aware without `/u`, so
101
+ * a naive `re.ASCII` port would fork on the SEPARATOR instead.
102
+ */
103
+ const NEIGHBOURS = [
104
+ "",
105
+ " ",
106
+ "\n",
107
+ "\t",
108
+ ".",
109
+ ",",
110
+ ")",
111
+ '"',
112
+ "'",
113
+ "-",
114
+ "_",
115
+ "/",
116
+ "é", // é — accented Latin
117
+ "к", // к — Cyrillic
118
+ "中", // 中 — CJK (no word spacing: the ordinary case)
119
+ "あ", // あ — Japanese kana
120
+ "ก", // ก — Thai
121
+ "ω", // ω — Greek
122
+ " ", // NBSP — JS \s matches, ASCII \s does not
123
+ " ", // ideographic space — same
124
+ "", // BOM — same
125
+ "K", // K (Kelvin sign) — re.I folds this to 'k' in Unicode mode
126
+ "ſ", // ſ (long s) — re.I folds this to 's' in Unicode mode
127
+ ];
128
+
129
+ /** Prose that must survive both engines untouched. */
130
+ const NEGATIVES = [
131
+ "The password is required.",
132
+ "The password is incorrect.",
133
+ "Ken confirmed the password is unchanged.",
134
+ "password: required, minimum twelve characters, mixed case",
135
+ "POSTGRES_PASSWORD: vault:pg/password",
136
+ "postgres_password: vault:pg/password",
137
+ "PASSWORD: ${DB_PASSWORD}",
138
+ "JWT_SECRET=<generate-with-openssl-rand>",
139
+ "ANTHROPIC_API_KEY: vault:anthropic/api_key",
140
+ "const API_KEY = process.env.ANTHROPIC_API_KEY",
141
+ "--token <value> the API token",
142
+ '{"token": "the bearer token to use"}',
143
+ "I told him the answer was probably Gandalf the Grey.",
144
+ "他のパスワードは安全です。",
145
+ ];
146
+
147
+ interface Case {
148
+ text: string;
149
+ family: string;
150
+ pre: string;
151
+ post: string;
152
+ /**
153
+ * True when the neighbours leave the rule's `\b` anchors intact, i.e.
154
+ * when a hit is EXPECTED. An empty neighbour glues the credential to
155
+ * the surrounding `note`/`end` filler and a `_` is itself a word
156
+ * character, so `noteAKIA…` is legitimately not a token in EITHER
157
+ * engine — those cases still have to AGREE, but they must not be
158
+ * asserted to mask.
159
+ */
160
+ anchored: boolean;
161
+ }
162
+
163
+ const isWordChar = (s: string) => /^[A-Za-z0-9_]$/.test(s);
164
+
165
+ function buildCorpus(): Case[] {
166
+ const cases: Case[] = [];
167
+ for (const [family, value] of Object.entries(FAMILIES)) {
168
+ for (const pre of NEIGHBOURS) {
169
+ for (const post of NEIGHBOURS) {
170
+ // `note` / `end` supply the effective neighbour when the slot is
171
+ // empty.
172
+ const anchored =
173
+ !isWordChar(pre === "" ? "e" : pre) &&
174
+ !isWordChar(post === "" ? "e" : post);
175
+ cases.push({
176
+ text: `note${pre}${value}${post}end`,
177
+ family,
178
+ pre,
179
+ post,
180
+ anchored,
181
+ });
182
+ }
183
+ }
184
+ }
185
+ for (const text of NEGATIVES) {
186
+ cases.push({ text, family: "negative", pre: "", post: "", anchored: false });
187
+ }
188
+ return cases;
189
+ }
190
+
191
+ /** `U+0041` style, so a failure message never carries secret bytes. */
192
+ function cp(s: string): string {
193
+ if (s === "") return "none";
194
+ return `U+${s.codePointAt(0)!.toString(16).toUpperCase().padStart(4, "0")}`;
195
+ }
196
+
197
+ // ─── Tests ────────────────────────────────────────────────────────────
198
+
199
+ describe("TS and Python redactors agree (differential)", () => {
200
+ const corpus = buildCorpus();
201
+ const tsOut = corpus.map((c) => redact(c.text));
202
+ const pyOut = pythonRedactAll(corpus.map((c) => c.text));
203
+
204
+ it("covers every credential family across a wide neighbour matrix", () => {
205
+ // A shrinking corpus would silently weaken the guarantee.
206
+ expect(corpus.length).toBeGreaterThanOrEqual(1000);
207
+ expect(Object.keys(FAMILIES).length).toBeGreaterThanOrEqual(14);
208
+ });
209
+
210
+ it("produces byte-identical output for every case", () => {
211
+ const divergences = corpus
212
+ .map((c, i) => ({ c, i }))
213
+ .filter(({ i }) => tsOut[i] !== pyOut[i])
214
+ .map(({ c, i }) => ({
215
+ family: c.family,
216
+ pre: cp(c.pre),
217
+ post: cp(c.post),
218
+ // Booleans only — never the text.
219
+ tsMasked: tsOut[i]!.includes("[REDACTED"),
220
+ pyMasked: pyOut[i]!.includes("[REDACTED"),
221
+ }));
222
+ expect(divergences).toEqual([]);
223
+ });
224
+
225
+ it("never lets one engine store a credential the other masked", () => {
226
+ // The asymmetric half of the fork, stated as its own outcome: this is
227
+ // the assertion that would have gone red on the shipped code.
228
+ const leaks = corpus
229
+ .map((c, i) => ({ c, i }))
230
+ .filter(
231
+ ({ c, i }) =>
232
+ c.family !== "negative" &&
233
+ !pyOut[i]!.includes("[REDACTED") &&
234
+ tsOut[i]!.includes("[REDACTED"),
235
+ )
236
+ .map(({ c }) => ({ family: c.family, pre: cp(c.pre), post: cp(c.post) }));
237
+ expect(leaks).toEqual([]);
238
+ });
239
+
240
+ it("masks the credential in both engines for every anchored case", () => {
241
+ const anchored = corpus
242
+ .map((c, i) => ({ c, i }))
243
+ .filter(({ c }) => c.anchored);
244
+ // Sanity: the anchored subset must still be the bulk of the matrix,
245
+ // otherwise the filter above could silently hollow this out.
246
+ expect(anchored.length).toBeGreaterThanOrEqual(600);
247
+ const missed = anchored
248
+ .filter(
249
+ ({ i }) =>
250
+ !(tsOut[i]!.includes("[REDACTED") && pyOut[i]!.includes("[REDACTED")),
251
+ )
252
+ .map(({ c }) => ({ family: c.family, pre: cp(c.pre), post: cp(c.post) }));
253
+ expect(missed).toEqual([]);
254
+ });
255
+
256
+ it("leaves prose untouched in both engines", () => {
257
+ for (const [i, c] of corpus.entries()) {
258
+ if (c.family !== "negative") continue;
259
+ expect(tsOut[i]).toBe(c.text);
260
+ expect(pyOut[i]).toBe(c.text);
261
+ }
262
+ });
263
+ });
@@ -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
  })