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.
- package/dist/auth-broker/index.js +7 -1
- package/dist/cli/skill-validate-pretool.mjs +15 -2
- package/dist/cli/switchroom.js +1005 -482
- package/dist/host-control/main.js +156 -4
- package/dist/vault/approvals/kernel-server.js +7 -1
- package/dist/vault/broker/server.js +7 -1
- package/package.json +4 -2
- package/profiles/_base/start.sh.hbs +37 -12
- package/telegram-plugin/dist/gateway/gateway.js +476 -116
- package/telegram-plugin/format.ts +70 -15
- package/telegram-plugin/gateway/gateway.ts +27 -31
- package/telegram-plugin/gateway/ipc-server.ts +18 -15
- package/telegram-plugin/gateway/model-command.ts +279 -23
- package/telegram-plugin/gateway/outbound-send-path.ts +12 -1
- package/telegram-plugin/operator-events.ts +40 -16
- 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 +93 -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/model-command.test.ts +415 -4
- package/telegram-plugin/tests/outbound-send-path.test.ts +1 -1
- 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/subagent_retain.py +103 -7
- package/vendor/hindsight-memory/scripts/tests/test_secret_redact.py +522 -0
- 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)
|
|
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
|
})
|