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,397 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_purpose": "Shared behaviour vectors for the secret redactor. The TypeScript engine (telegram-plugin/secret-detect/redact.ts) and the Python engine (vendor/hindsight-memory/scripts/lib/secret_redact.py) must produce IDENTICAL output for every case here. Asserted by telegram-plugin/tests/secret-detect-write-path.test.ts and vendor/hindsight-memory/scripts/tests/test_secret_redact.py.",
|
|
3
|
+
"_secrets_note": "Every value below is synthetic. Token-shaped strings are assembled from repeated filler so no real credential is committed and GitHub Push Protection has nothing to match.",
|
|
4
|
+
"vectors": [
|
|
5
|
+
{
|
|
6
|
+
"name": "postgres connection string password is masked",
|
|
7
|
+
"input": "connect with postgres://appuser:HunterTwo99@db.internal:5432/prod",
|
|
8
|
+
"expected": "connect with postgres://appuser:[REDACTED:db_uri_password]@db.internal:5432/prod"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"name": "mysql connection string password is masked",
|
|
12
|
+
"input": "mysql://root:t0psecretpw@127.0.0.1:3306/app",
|
|
13
|
+
"expected": "mysql://root:[REDACTED:db_uri_password]@127.0.0.1:3306/app"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"name": "mongodb+srv connection string password is masked",
|
|
17
|
+
"input": "MONGO=mongodb+srv://svc:aB3dEf9hJ2@cluster0.example.net/db",
|
|
18
|
+
"expected": "MONGO=mongodb+srv://svc:[REDACTED:db_uri_password]@cluster0.example.net/db"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"name": "redis connection string password is masked",
|
|
22
|
+
"input": "redis://default:cachePass42@redis.internal:6379/0",
|
|
23
|
+
"expected": "redis://default:[REDACTED:db_uri_password]@redis.internal:6379/0"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"name": "postgres URI without credentials is untouched",
|
|
27
|
+
"input": "postgres://db.internal:5432/prod is the read replica",
|
|
28
|
+
"expected": "postgres://db.internal:5432/prod is the read replica"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "already-masked connection string is stable",
|
|
32
|
+
"input": "postgres://appuser:[REDACTED:db_uri_password]@db.internal:5432/prod",
|
|
33
|
+
"expected": "postgres://appuser:[REDACTED:db_uri_password]@db.internal:5432/prod"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"name": "human-memorable password after a colon is masked",
|
|
37
|
+
"input": "password: FluffyBarnaby1998",
|
|
38
|
+
"expected": "password: [REDACTED:memorable_password]"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"name": "human-memorable password in prose is masked",
|
|
42
|
+
"input": "the wifi password is MangoTreeHouse77 if you need it",
|
|
43
|
+
"expected": "the wifi password is [REDACTED:memorable_password] if you need it"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"name": "prefixed password identifier is masked",
|
|
47
|
+
"input": "db_password=Rutherford2024x",
|
|
48
|
+
"expected": "db_password=[REDACTED:memorable_password]"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"name": "passphrase label is covered",
|
|
52
|
+
"input": "passphrase: CorrectHorse9Battery",
|
|
53
|
+
"expected": "passphrase: [REDACTED:memorable_password]"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"name": "single-class prose after 'password is' is NOT masked",
|
|
57
|
+
"input": "the password is rotated quarterly by the platform team",
|
|
58
|
+
"expected": "the password is rotated quarterly by the platform team"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"name": "password policy prose is NOT masked",
|
|
62
|
+
"input": "The password policy requires rotation every 90 days.",
|
|
63
|
+
"expected": "The password policy requires rotation every 90 days."
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"name": "shell variable reference is NOT masked",
|
|
67
|
+
"input": "password: ${DB_PASSWORD}",
|
|
68
|
+
"expected": "password: ${DB_PASSWORD}"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"name": "angle-bracket placeholder is NOT masked",
|
|
72
|
+
"input": "password=<your-password-here>",
|
|
73
|
+
"expected": "password=<your-password-here>"
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"name": "vault reference is NOT masked",
|
|
77
|
+
"input": "password: vault:postgres/app_password",
|
|
78
|
+
"expected": "password: vault:postgres/app_password"
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"name": "already-masked memorable password is stable",
|
|
82
|
+
"input": "password: [REDACTED:memorable_password]",
|
|
83
|
+
"expected": "password: [REDACTED:memorable_password]"
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"name": "https url credentials are stripped by the url pass",
|
|
87
|
+
"input": "see https://alice:s3cretpw@api.example.com/v1/things",
|
|
88
|
+
"expected": "see https://***@api.example.com/v1/things"
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"name": "sensitive query param is masked",
|
|
92
|
+
"input": "hit https://api.example.com/v1?api_key=abcdef123456&trace=42",
|
|
93
|
+
"expected": "hit https://api.example.com/v1?api_key=***&trace=42"
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"name": "plain url is untouched",
|
|
97
|
+
"input": "docs at https://example.com/guide?page=2",
|
|
98
|
+
"expected": "docs at https://example.com/guide?page=2"
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
"name": "ordinary prose is untouched",
|
|
102
|
+
"input": "I told him the answer was probably Gandalf the Grey.",
|
|
103
|
+
"expected": "I told him the answer was probably Gandalf the Grey."
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"name": "empty string passes through",
|
|
107
|
+
"input": "",
|
|
108
|
+
"expected": ""
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"name": "multi-secret line masks every hit",
|
|
112
|
+
"input": "db postgres://u:PineappleRoof8@h/d and wifi password: SparrowKettle31",
|
|
113
|
+
"expected": "db postgres://u:[REDACTED:db_uri_password]@h/d and wifi password: [REDACTED:memorable_password]"
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
"name": "anthropic api key is masked",
|
|
117
|
+
"input": "the key is \u0073k-ant-api03-AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJ ok",
|
|
118
|
+
"expected": "the key is [REDACTED:anthropic_api_key] ok"
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"name": "anthropic api key adjacent to CJK text is masked",
|
|
122
|
+
"input": "トークンは\u0073k-ant-api03-AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJです",
|
|
123
|
+
"expected": "トークンは[REDACTED:anthropic_api_key]です"
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"name": "openai style key is masked",
|
|
127
|
+
"input": "OPENAI sk-AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJKKKK",
|
|
128
|
+
"expected": "OPENAI [REDACTED:openai_api_key]"
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"name": "github pat is masked next to Cyrillic text",
|
|
132
|
+
"input": "ключ \u0067hp_AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIк",
|
|
133
|
+
"expected": "ключ [REDACTED:github_pat_classic]к"
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
"name": "aws access key id is masked",
|
|
137
|
+
"input": "\u0041KIAAAAABBBBCCCCDDDD is the id",
|
|
138
|
+
"expected": "[REDACTED:aws_access_key] is the id"
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
"name": "google api key is masked",
|
|
142
|
+
"input": "\u0041IzaAAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIII",
|
|
143
|
+
"expected": "[REDACTED:google_api_key]"
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"name": "slack bot token is masked",
|
|
147
|
+
"input": "\u0078oxb-111111111111-AAAABBBBCCCCDDDDEEEEFFFF",
|
|
148
|
+
"expected": "[REDACTED:slack_token]"
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"name": "jwt is masked next to accented latin text",
|
|
152
|
+
"input": "jetón eyJAAAABBBBCCCC.DDDDEEEEFFFF.GGGGHHHHIIIIé",
|
|
153
|
+
"expected": "jetón [REDACTED:jwt]é"
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
"name": "telegram bot token is masked",
|
|
157
|
+
"input": "bot1234567:AAAABBBBCCCCDDDDEEEEFFFFGGGGHH",
|
|
158
|
+
"expected": "bot[REDACTED:telegram_bot_token_prefixed]"
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
"name": "sanctum style token is masked",
|
|
162
|
+
"input": "17|AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJ",
|
|
163
|
+
"expected": "[REDACTED:laravel_sanctum_token]"
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
"name": "uppercase env assignment is masked",
|
|
167
|
+
"input": "API_TOKEN=zQ7xVb2nKd9wRt4y",
|
|
168
|
+
"expected": "API_TOKEN=[REDACTED]"
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
"name": "uppercase env assignment adjacent to Thai text is masked",
|
|
172
|
+
"input": "กุญ API_TOKEN=zQ7xVb2nKd9wRt4yก",
|
|
173
|
+
"expected": "กุญ API_TOKEN=[REDACTED]"
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
"name": "authorization bearer header is masked",
|
|
177
|
+
"input": "Authorization: Bearer AAAABBBBCCCCDDDDEEEEFFFF",
|
|
178
|
+
"expected": "Authorization: Bearer [REDACTED:bearer_auth_header]"
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
"name": "lowercase authorization bearer header is masked",
|
|
182
|
+
"input": "authorization: bearer AAAABBBBCCCCDDDDEEEEFFFF",
|
|
183
|
+
"expected": "authorization: bearer [REDACTED:bearer_auth_header]"
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
"name": "authorization basic header is masked",
|
|
187
|
+
"input": "Authorization: Basic YWxpY2U6c3VwZXJzZWNyZXQ=",
|
|
188
|
+
"expected": "Authorization: Basic [REDACTED:basic_auth_header]"
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
"name": "json secret field is masked",
|
|
192
|
+
"input": "{\"apiKey\": \"zQ7xVb2nKd9wRt4y\"}",
|
|
193
|
+
"expected": "{\"apiKey\": \"[REDACTED:json_secret_field]\"}"
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
"name": "cli flag value is masked",
|
|
197
|
+
"input": "run --api-key zQ7xVb2nKd9wRt4y",
|
|
198
|
+
"expected": "run --api-key [REDACTED:cli_flag]"
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
"name": "no-break-space (U+00A0) separator after the key still masks",
|
|
202
|
+
"input": "API_TOKEN: zQ7xVb2nKd9wRt4y",
|
|
203
|
+
"expected": "API_TOKEN: [REDACTED]"
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
"name": "pem private key block is masked",
|
|
207
|
+
"input": "-----BEGIN RSA PRIVATE KEY-----\nAAAABBBBCCCC\n-----END RSA PRIVATE KEY-----",
|
|
208
|
+
"expected": "[REDACTED:pem_private_key]"
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
"name": "sentence-final 'required' after 'password is' is NOT masked",
|
|
212
|
+
"input": "The password is required.",
|
|
213
|
+
"expected": "The password is required."
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
"name": "sentence-final 'incorrect' after 'password is' is NOT masked",
|
|
217
|
+
"input": "The password is incorrect.",
|
|
218
|
+
"expected": "The password is incorrect."
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
"name": "sentence-final 'unchanged' after 'password is' is NOT masked",
|
|
222
|
+
"input": "Ken confirmed the password is unchanged.",
|
|
223
|
+
"expected": "Ken confirmed the password is unchanged."
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
"name": "comma-terminated prose after a password label is NOT masked",
|
|
227
|
+
"input": "password: required, minimum twelve characters, mixed case",
|
|
228
|
+
"expected": "password: required, minimum twelve characters, mixed case"
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
"name": "uppercase env vault reference is NOT masked",
|
|
232
|
+
"input": "POSTGRES_PASSWORD: vault:pg/password",
|
|
233
|
+
"expected": "POSTGRES_PASSWORD: vault:pg/password"
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
"name": "lowercase env vault reference is NOT masked",
|
|
237
|
+
"input": "postgres_password: vault:pg/password",
|
|
238
|
+
"expected": "postgres_password: vault:pg/password"
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
"name": "uppercase env shell placeholder is NOT masked",
|
|
242
|
+
"input": "PASSWORD: ${DB_PASSWORD}",
|
|
243
|
+
"expected": "PASSWORD: ${DB_PASSWORD}"
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
"name": "uppercase env angle placeholder is NOT masked",
|
|
247
|
+
"input": "JWT_SECRET=<generate-with-openssl-rand>",
|
|
248
|
+
"expected": "JWT_SECRET=<generate-with-openssl-rand>"
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
"name": "api key vault reference is NOT masked",
|
|
252
|
+
"input": "ANTHROPIC_API_KEY: vault:anthropic/api_key",
|
|
253
|
+
"expected": "ANTHROPIC_API_KEY: vault:anthropic/api_key"
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
"name": "process.env reference is NOT masked",
|
|
257
|
+
"input": "const API_KEY = process.env.ANTHROPIC_API_KEY",
|
|
258
|
+
"expected": "const API_KEY = process.env.ANTHROPIC_API_KEY"
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
"name": "cli flag placeholder in help text is NOT masked",
|
|
262
|
+
"input": "--token <value> the API token",
|
|
263
|
+
"expected": "--token <value> the API token"
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
"name": "changeme placeholder is NOT masked",
|
|
267
|
+
"input": "DB_PASSWORD=changeme-in-production",
|
|
268
|
+
"expected": "DB_PASSWORD=changeme-in-production"
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
"name": "json doc string is NOT masked",
|
|
272
|
+
"input": "{\"token\": \"the bearer token to use\"}",
|
|
273
|
+
"expected": "{\"token\": \"the bearer token to use\"}"
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
"name": "ssh public key is NOT masked",
|
|
277
|
+
"input": "ssh-rsa AAAAB3NzaAAAABBBBCCCCDDDDEEEEFFFFGGGGHHHH deploy@host",
|
|
278
|
+
"expected": "ssh-rsa AAAAB3NzaAAAABBBBCCCCDDDDEEEEFFFFGGGGHHHH deploy@host"
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
"name": "dsn password containing an unencoded at-sign is fully masked",
|
|
282
|
+
"input": "postgres://appuser:p@ssW0rd123@db.internal:5432/prod",
|
|
283
|
+
"expected": "postgres://appuser:[REDACTED:db_uri_password]@db.internal:5432/prod"
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
"name": "memorable password at the end of a sentence is masked",
|
|
287
|
+
"input": "The wifi password is MangoTreeHouse77.",
|
|
288
|
+
"expected": "The wifi password is [REDACTED:memorable_password]"
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
"name": "inert angle wrapper does NOT smuggle a value past env_key_value",
|
|
292
|
+
"input": "POSTGRES_PASSWORD: <a8f3c1d2e4b50f6a9c7d3e81b2f45a6cd90e17bb>",
|
|
293
|
+
"expected": "POSTGRES_PASSWORD: [REDACTED]"
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
"name": "inert angle wrapper does NOT smuggle a value past json_secret_field",
|
|
297
|
+
"input": "{\"token\": \"<tok_Zq7xVb2nKd9wRt4y>\"}",
|
|
298
|
+
"expected": "{\"token\": \"[REDACTED:json_secret_field]\"}"
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
"name": "inert angle wrapper does NOT smuggle a value past cli_flag",
|
|
302
|
+
"input": "--token <a8f3c1d2e4b50f6a9c7d3e81b2f45a6cd90e17bb>",
|
|
303
|
+
"expected": "--token [REDACTED:cli_flag]"
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
"name": "handlebars wrapper does NOT smuggle a value through",
|
|
307
|
+
"input": "POSTGRES_PASSWORD: {{a8f3c1d2e4b50f6a9c7d3e81b2f45a6cd90e17bb}}",
|
|
308
|
+
"expected": "POSTGRES_PASSWORD: [REDACTED]"
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
"name": "handlebars wrapper does NOT smuggle a value past cli_flag",
|
|
312
|
+
"input": "--token {{tok_Zq7xVb2nKd9wRt4y}}",
|
|
313
|
+
"expected": "--token [REDACTED:cli_flag]"
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
"name": "vault: prefix does NOT smuggle a trailing value through",
|
|
317
|
+
"input": "POSTGRES_PASSWORD: vault:pg/password<a8f3c1d2e4b50f6a9c7d3e81b2f45a6cd90e17bb>",
|
|
318
|
+
"expected": "POSTGRES_PASSWORD: [REDACTED]"
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
"name": "vault: key slot does NOT smuggle a value through",
|
|
322
|
+
"input": "{\"token\": \"vault:pg/a8f3c1d2e4b50f6a9c7d3e81b2f45a6cd90e17bb\"}",
|
|
323
|
+
"expected": "{\"token\": \"[REDACTED:json_secret_field]\"}"
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
"name": "[REDACTED] marker prefix does NOT smuggle a trailing value through",
|
|
327
|
+
"input": "POSTGRES_PASSWORD: [REDACTED]a8f3c1d2e4b50f6a9c7d3e81b2f45a6cd90e17bb",
|
|
328
|
+
"expected": "POSTGRES_PASSWORD: [REDACTED]"
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
"name": "[REDACTED:rule] marker prefix does NOT smuggle a trailing value through",
|
|
332
|
+
"input": "--token [REDACTED:cli_flag]a8f3c1d2e4b50f6a9c7d3e81b2f45a6cd90e17bb",
|
|
333
|
+
"expected": "--token [REDACTED:cli_flag]"
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
"name": "process.env prefix does NOT smuggle a trailing value through",
|
|
337
|
+
"input": "POSTGRES_PASSWORD: process.env.a8f3c1d2e4b50f6a9c7d3e81b2f45a6cd90e17bb",
|
|
338
|
+
"expected": "POSTGRES_PASSWORD: [REDACTED]"
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
"name": "changeme prefix does NOT smuggle a trailing value through",
|
|
342
|
+
"input": "{\"token\": \"changeme-a8f3c1d2e4b50f6a9c7d3e81b2f45a6cd90e17bb\"}",
|
|
343
|
+
"expected": "{\"token\": \"[REDACTED:json_secret_field]\"}"
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
"name": "todo prefix does NOT smuggle a trailing value through",
|
|
347
|
+
"input": "--token todo_a8f3c1d2e4b50f6a9c7d3e81b2f45a6cd90e17bb",
|
|
348
|
+
"expected": "--token [REDACTED:cli_flag]"
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
"name": "handlebars placeholder is NOT masked",
|
|
352
|
+
"input": "POSTGRES_PASSWORD: {{ db_password }}",
|
|
353
|
+
"expected": "POSTGRES_PASSWORD: {{ db_password }}"
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
"name": "os.environ subscript reference is NOT masked",
|
|
357
|
+
"input": "API_KEY = os.environ[\"ANTHROPIC_API_KEY\"]",
|
|
358
|
+
"expected": "API_KEY = os.environ[\"ANTHROPIC_API_KEY\"]"
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
"name": "import.meta.env reference is NOT masked",
|
|
362
|
+
"input": "VITE_API_KEY = import.meta.env.VITE_API_KEY",
|
|
363
|
+
"expected": "VITE_API_KEY = import.meta.env.VITE_API_KEY"
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
"name": "already-masked env value is stable under a second pass",
|
|
367
|
+
"input": "POSTGRES_PASSWORD: [REDACTED]",
|
|
368
|
+
"expected": "POSTGRES_PASSWORD: [REDACTED]"
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
"name": "already-masked rule-tagged cli flag is stable under a second pass",
|
|
372
|
+
"input": "--token [REDACTED:cli_flag]",
|
|
373
|
+
"expected": "--token [REDACTED:cli_flag]"
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
"name": "bare todo placeholder is NOT masked",
|
|
377
|
+
"input": "POSTGRES_PASSWORD: todo-before-launch",
|
|
378
|
+
"expected": "POSTGRES_PASSWORD: todo-before-launch"
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
"name": "single-class 16-char run in an angle wrapper is still masked",
|
|
382
|
+
"input": "POSTGRES_PASSWORD: <abcdefghijklmnop>",
|
|
383
|
+
"expected": "POSTGRES_PASSWORD: [REDACTED]"
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
"name": "single-class 16-char run in a handlebars wrapper is still masked",
|
|
387
|
+
"input": "--token {{abcdefghijklmnop}}",
|
|
388
|
+
"expected": "--token [REDACTED:cli_flag]"
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
"name": "a 10-letter word inside a placeholder is NOT masked",
|
|
392
|
+
"input": "DB_PASSWORD=changeme-in-production",
|
|
393
|
+
"expected": "DB_PASSWORD=changeme-in-production"
|
|
394
|
+
}
|
|
395
|
+
],
|
|
396
|
+
"_coverage": "Two classes of vector live here. (a) The IMPERATIVE gates that cannot be generated: the entropy floors, the inert-value list, the memorable-password character-class rule, the connection-URI scanner. (b) The GENERATED pattern table itself -- one vector per credential family, several of them deliberately ADJACENT to non-ASCII text. (b) exists because #3982's review found the two engines forking on Unicode word boundaries (Python re is Unicode-aware, JS RegExp without /u is not) while the byte-compare parity guard stayed green: the SOURCES were identical, the BEHAVIOUR was not, and no vector touched a generated rule. Keep at least one non-ASCII-adjacent vector per new family. Every credential here is synthetic. JSON has no runtime string concatenation, so token-shaped prefixes are broken up with \\uXXXX escapes instead -- json.load / JSON.parse restore the intended bytes, while the file on disk holds no contiguous token literal (CLAUDE.md 'Secrets in tests', scripts/check-no-pii-secrets.mjs, and GitHub Push Protection all require that). The WRAPPER vectors (#3982 review MAJOR 7) pin the other half of the inert gate: a value is skipped only when the WHOLE value is inert, so an inert-looking prefix or bracket pair left around a real credential must NOT suppress the mask."
|
|
397
|
+
}
|
|
@@ -3,11 +3,24 @@
|
|
|
3
3
|
|
|
4
4
|
Delegated (sub-agent / Task-tool) work is the biggest systematic memory hole:
|
|
5
5
|
the main-session Stop retain only ever reads the parent ``transcript_path``, so
|
|
6
|
-
a worker's hours of
|
|
7
|
-
|
|
6
|
+
a worker's hours of reasoning — the constraints it discovered, the structural
|
|
7
|
+
dead ends it ruled out — reach memory only as the terse final report the parent
|
|
8
8
|
transcript captures. This hook closes that hole by retaining a bounded window of
|
|
9
9
|
the *sidechain* transcript when a sub-agent terminates.
|
|
10
10
|
|
|
11
|
+
Scope (Ken, 2026-07-29): LEARNINGS, not raw transcripts and tools. The window is
|
|
12
|
+
retained on the TEXT-ONLY formatting path (``run_subagent_retain`` forces
|
|
13
|
+
``retainToolCalls = False`` on its config copy), so what reaches memory is the
|
|
14
|
+
sub-agent's own prose — reasoning, findings, final report — and NOT tool_use
|
|
15
|
+
inputs, tool_result bodies, file contents or diffs.
|
|
16
|
+
|
|
17
|
+
The text-only path cannot format to nothing here: the volume gate already
|
|
18
|
+
requires ``MIN_HUMAN_TURNS`` GENUINE human turns (tool_result-only user messages
|
|
19
|
+
are explicitly not counted, ``count_human_turns``), and every such turn is a
|
|
20
|
+
plain-string user message that ``_extract_text_content`` returns verbatim. So a
|
|
21
|
+
window that clears the gate always carries at least its instruction turns, and
|
|
22
|
+
``build_retain_payload`` never returns None on this path for volume reasons.
|
|
23
|
+
|
|
11
24
|
Probe result (Claude Code 2.1.215, PR5 Task 0 — recorded in the PR body):
|
|
12
25
|
the ``SubagentStop`` hook input carries BOTH the parent ``transcript_path`` AND
|
|
13
26
|
a first-class ``agent_transcript_path`` pointing straight at the sidechain
|
|
@@ -62,8 +75,11 @@ from lib.pacing import inflight_lock
|
|
|
62
75
|
# stays byte-identical to the main path where it matters (dedup ids, formatting).
|
|
63
76
|
from retain import build_retain_payload, read_transcript
|
|
64
77
|
|
|
65
|
-
# Retain the last N human turns of the sidechain.
|
|
66
|
-
#
|
|
78
|
+
# Retain the last N human turns of the sidechain. The window is formatted on the
|
|
79
|
+
# TEXT-ONLY path (``retainToolCalls`` is forced False for the sidechain — see
|
|
80
|
+
# ``run_subagent_retain``), so tool_use inputs and tool_result bodies are dropped
|
|
81
|
+
# entirely rather than passed through / truncated. ``_extract_message_blocks`` is
|
|
82
|
+
# still imported here, but only for the volume gate's char count.
|
|
67
83
|
SIDECHAIN_WINDOW_TURNS = 40
|
|
68
84
|
|
|
69
85
|
# Volume gate floors — SubagentStop fires for every Task, so skip trivial forks.
|
|
@@ -256,6 +272,14 @@ def non_tool_result_char_count(messages: list, stop_at: int | None = None) -> in
|
|
|
256
272
|
the floor even if it emitted a large tool_result, while a real worker's
|
|
257
273
|
commands and decisions count.
|
|
258
274
|
|
|
275
|
+
KNOWN MISMATCH (accepted, tracked as a follow-up): this counts ``tool_use``
|
|
276
|
+
inputs, but those are no longer RETAINED — the sidechain payload is built on
|
|
277
|
+
the text-only path. So the gate can clear on tool volume that contributes
|
|
278
|
+
nothing to the stored memory. It cannot produce an EMPTY retain (the
|
|
279
|
+
``MIN_HUMAN_TURNS`` floor guarantees prose-bearing user turns — see the
|
|
280
|
+
module docstring), so this is a precision issue in the gate, not a
|
|
281
|
+
correctness bug. Tightening it to count only text is a separate change.
|
|
282
|
+
|
|
259
283
|
``stop_at`` (review finding 4 — early short-circuit): return as soon as the
|
|
260
284
|
running total reaches this many chars. The gate only needs to know whether
|
|
261
285
|
the floor is CLEARED, not the exact size — so on a large worker transcript
|
|
@@ -396,9 +420,32 @@ def run_subagent_retain(hook_input: dict) -> dict:
|
|
|
396
420
|
# main-session retains: reuse retain.py's ``slice_document_id`` recipe via a
|
|
397
421
|
# composite session key so (a) re-fires of the SAME sub-agent window upsert
|
|
398
422
|
# server-side, and (b) it never collides with the parent's own
|
|
399
|
-
# ``{session_id}-r...`` documents.
|
|
400
|
-
#
|
|
401
|
-
#
|
|
423
|
+
# ``{session_id}-r...`` documents.
|
|
424
|
+
#
|
|
425
|
+
# Client-side diffing against the parent's final-report retain is still NOT
|
|
426
|
+
# attempted, but NOT because "hindsight's consolidation dedups" — the
|
|
427
|
+
# original claim here (design item 4) was FALSE and is the assumption that
|
|
428
|
+
# licensed the sidechain volume. Verified against the engine source
|
|
429
|
+
# (upstream image ``ghcr.io/vectorize-io/hindsight``):
|
|
430
|
+
# * The only SEMANTIC dedup lives in
|
|
431
|
+
# ``hindsight_api/engine/consolidation/consolidator.py`` and is a guard
|
|
432
|
+
# on the consolidator's OWN OUTPUT: ``_dedup_adjudicate`` probes a newly
|
|
433
|
+
# created/updated ``observation`` against existing ones, passing the
|
|
434
|
+
# literal fact-type list ``["observation"]`` to
|
|
435
|
+
# ``retrieve_semantic_bm25_combined``.
|
|
436
|
+
# * ``world`` and ``experience`` are the raw extracted facts that FEED the
|
|
437
|
+
# consolidator (it selects ``fact_type IN ('experience', 'world')`` for
|
|
438
|
+
# unconsolidated rows) — they never traverse the observation dedup path.
|
|
439
|
+
# * Grepping ``hindsight_api/engine/retain/*.py`` for dedup finds only
|
|
440
|
+
# content-hash CHUNK dedup (``chunk_storage.compute_chunk_hash``), i.e.
|
|
441
|
+
# byte-identical-chunk skipping. There is no semantic dedup on the
|
|
442
|
+
# retain path at all.
|
|
443
|
+
# So overlap between a sidechain retain and the parent's own retain persists
|
|
444
|
+
# as extra ``world``/``experience`` rows forever. Volume control has to come
|
|
445
|
+
# from retaining LESS (see ``retainToolCalls`` below), not from a downstream
|
|
446
|
+
# dedup that does not exist. Diffing is still skipped here because the
|
|
447
|
+
# deterministic document_id already makes re-fires of the SAME window upsert,
|
|
448
|
+
# which is the duplicate class this path can actually create.
|
|
402
449
|
sub_session_id = f"{session_id}-sub-{agent_id}"
|
|
403
450
|
|
|
404
451
|
# Sidechain tags + a topic-friendly parent link. Reuse the config-driven tag
|
|
@@ -407,6 +454,55 @@ def run_subagent_retain(hook_input: dict) -> dict:
|
|
|
407
454
|
# (recallTagWeights); ``parent_session:<id>`` lets a fresh session pull a
|
|
408
455
|
# worker's process facts by parent.
|
|
409
456
|
sub_config = dict(config)
|
|
457
|
+
|
|
458
|
+
# Learnings, not raw transcripts and tools (Ken, 2026-07-29): "I don't want
|
|
459
|
+
# sub-agents' transcripts but definitely their learnings should be captured
|
|
460
|
+
# but not raw transcripts and tools."
|
|
461
|
+
#
|
|
462
|
+
# With ``retainToolCalls`` on, ``build_retain_payload`` formats the window
|
|
463
|
+
# via ``_prepare_json_transcript`` → ``_extract_message_blocks``, which emits
|
|
464
|
+
# every ``tool_use.input`` verbatim (an entire ``Write.content``, a full
|
|
465
|
+
# ``Edit`` diff, a full ``Bash.command``) plus every ``tool_result`` at up to
|
|
466
|
+
# 2,000 chars per block.
|
|
467
|
+
#
|
|
468
|
+
# The cost mechanism is DOCUMENT FAN-OUT, not one oversized payload: a large
|
|
469
|
+
# payload is not truncated, it is CAPPED and SPLIT by
|
|
470
|
+
# ``lib.retain_split.split_retain_content`` at ``retain_content_limit()``
|
|
471
|
+
# (33,000 chars on the shipped inputs) into ``{base}-p{i}of{n}`` parts
|
|
472
|
+
# (``client.py`` ~:235). Real sidechain retains have been observed splitting
|
|
473
|
+
# into 38 parts / 1,022 messages, and every part is separately chunked and
|
|
474
|
+
# LLM-extracted into ``world``/``experience`` rows. Shrinking the content is
|
|
475
|
+
# therefore the lever that reduces rows.
|
|
476
|
+
#
|
|
477
|
+
# Forcing it OFF for the sidechain path routes the window through
|
|
478
|
+
# ``_prepare_text_transcript`` → ``_extract_text_content``, keeping assistant
|
|
479
|
+
# ``text`` blocks and channel-message tool_use text. Measured on real fleet
|
|
480
|
+
# sidechains: 5.5x smaller over 30 sampled transcripts (2,012,376 → 367,796
|
|
481
|
+
# chars), and independently 6.4x over the 84 most recent GATE-PASSING ones
|
|
482
|
+
# (26,278,261 → 4,085,678 chars) — of which 0 formatted to empty and 0 came
|
|
483
|
+
# out under 500 chars.
|
|
484
|
+
#
|
|
485
|
+
# ACCEPTED LOSS — this is not a clean "tool noise only" filter.
|
|
486
|
+
# ``_extract_text_content`` also drops image blocks, ALL ``tool_result``
|
|
487
|
+
# content, and sub-agent Task report bodies. So a fact that existed ONLY in
|
|
488
|
+
# tool output is unrecoverable: if a query returned ``43442`` and the agent
|
|
489
|
+
# merely replied "checked, it's the backlog", the number is gone. That trade
|
|
490
|
+
# is deliberate and is what Ken asked for; sub-agent prose was separately
|
|
491
|
+
# measured to carry durable learnings at essentially the main-session rate
|
|
492
|
+
# (5.24% vs 6.87%), so the learnings themselves survive.
|
|
493
|
+
#
|
|
494
|
+
# Mid-session safety: ``slice_document_id`` derives the BASE id from the
|
|
495
|
+
# slice's first/last uuids, not from the formatted text, so a re-fire of the
|
|
496
|
+
# same window still targets the same base id. Note the part SUFFIX embeds the
|
|
497
|
+
# part total, so a document that used to split into n parts and now splits
|
|
498
|
+
# into m < n leaves parts m+1..n in place — they are not overwritten and not
|
|
499
|
+
# duplicated. Historical sidechain rows are untouched by this change; this
|
|
500
|
+
# fixes INTAKE going forward only.
|
|
501
|
+
#
|
|
502
|
+
# Deliberately set on the COPY, not on ``config``: the parent session's own
|
|
503
|
+
# Stop retain keeps whatever the operator configured.
|
|
504
|
+
sub_config["retainToolCalls"] = False
|
|
505
|
+
|
|
410
506
|
base_tags = list(config.get("retainTags") or [])
|
|
411
507
|
extra_tags = ["sidechain", f"parent_session:{session_id}"]
|
|
412
508
|
if agent_type:
|