polygram 0.38.2 → 0.38.3

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.
@@ -17,12 +17,46 @@
17
17
  *
18
18
  * A rule may set `group` to redact only a capture group (the value), not the
19
19
  * whole match (e.g. the token after "Bearer", the value after "password:").
20
+ *
21
+ * Two consumers, two policies over the same rules:
22
+ * redactText — after-the-fact cleanup (sweep / redact tool):
23
+ * high+medium redacted, low only flagged.
24
+ * sanitizeForDurableWrite — the pre-write boundary: low is masked too,
25
+ * because an unresolved credential must not land
26
+ * in SQLite/FTS. A narrow value allowlist keeps
27
+ * ordinary vocabulary ("password: required")
28
+ * readable. The live turn keeps the original text.
20
29
  */
21
30
 
22
- const crypto = require('crypto');
23
-
24
31
  const PLACEHOLDER = (rule) => `‹redacted:${rule}›`; // ‹redacted:rule› — never re-matches a rule
25
32
 
33
+ /**
34
+ * The value half of a declared credential (`password: …`, `my password is …`).
35
+ *
36
+ * Three alternatives, tried in order, because a declared value has three
37
+ * shapes and stopping at the first whitespace gets two of them wrong:
38
+ * 1. double-quoted — everything up to the closing quote, spaces included
39
+ * 2. single-quoted — same
40
+ * 3. unquoted — up to a safe delimiter or end of line
41
+ *
42
+ * An unquoted value is ambiguous: "password: correct horse battery" could be
43
+ * three words of one passphrase or one word followed by prose. It is read as
44
+ * the passphrase and masked to the delimiter, because over-masking a stored
45
+ * copy is recoverable and under-masking a credential is not. Escaped quotes
46
+ * (`\"secret\"`, as a serializer would write them) fall into the unquoted
47
+ * branch and are masked with their backslashes.
48
+ *
49
+ * There is no minimum length: `password=abc` is as much a credential as a
50
+ * 40-character token. The benign-value allowlist, not a length floor, is what
51
+ * keeps "password: required" readable.
52
+ */
53
+ const VALUE = '(?:"([^"\\n]{1,256})"|\'([^\'\\n]{1,256})\'|([^\\s,;)}\\]\\n][^,;)}\\]\\n]{0,255}))';
54
+ const VALUE_GROUPS = [1, 2, 3];
55
+
56
+ // Keywords that declare a credential. Shared by the assignment and CLI-flag
57
+ // shapes so both stay in step.
58
+ const KV_KEYWORDS = 'password|passwd|pwd|secret|token|api[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret';
59
+
26
60
  const RULES = [
27
61
  // ── HIGH ──────────────────────────────────────────────────────────────
28
62
  { name: 'private-key', tier: 'high', re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g },
@@ -55,10 +89,78 @@ const RULES = [
55
89
  // ── MEDIUM ────────────────────────────────────────────────────────────
56
90
  { name: 'jwt', tier: 'medium', re: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
57
91
  // ── LOW (flag only) ───────────────────────────────────────────────────
58
- { name: 'kv-secret', tier: 'low', re: /\b(?:password|passwd|pwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret)\b\s*[:=]\s*["']?([^\s"']{6,})/gid, group: 1 },
92
+ // `token` is included in the ASSIGNMENT form only (`--token=…`, `token: …`):
93
+ // a bare noun in prose usually describes auth state, but an assignment to it
94
+ // is a credential. The plural ("tokens: 1500") can't match — \b requires a
95
+ // boundary right after the word.
96
+ // Two assignment shapes: `key: value` / `key=value`, and the CLI flag form
97
+ // `--password value`. The flag form requires the leading dash, so ordinary
98
+ // prose ("password required") cannot reach it — only a command line can.
99
+ // `--password-file /etc/x` is not a hit: the character after the keyword
100
+ // must be `=` or a space.
101
+ { name: 'kv-secret', tier: 'low', re: new RegExp(`(?:\\b(?:${KV_KEYWORDS})\\b\\s*[:=]\\s*|(?:^|[\\s"'])--?(?:${KV_KEYWORDS})[=\\s]\\s*)${VALUE}`, 'gid'), groups: VALUE_GROUPS },
102
+ // Prose form of the same declaration ("my password is hunter2"). Kept
103
+ // deliberately tight — a credential noun immediately followed by is/was and
104
+ // the value. Free prose that never names the credential is NOT detectable
105
+ // here; the shape must be declared for this rule to fire.
106
+ { name: 'prose-secret', tier: 'low', re: new RegExp(`\\b(?:password|passphrase|passwd|api[ _-]?key|access[ _-]?token|auth[ _-]?token|client[ _-]?secret)\\s+(?:is|was)\\s+${VALUE}`, 'gid'), groups: VALUE_GROUPS },
107
+ ];
108
+
109
+ /**
110
+ * Values a low-tier rule may capture that are ordinary vocabulary, not
111
+ * credentials ("password: required"). Deliberately small and deterministic:
112
+ * everything not listed here is masked at the durable boundary, so growing
113
+ * this list is the only way to weaken it. Compared case-insensitively against
114
+ * the FIRST word of the captured value, after trailing punctuation is
115
+ * trimmed, so a benign word introducing prose ("password: required for the
116
+ * staging box") is recognized even though the captured span runs on.
117
+ */
118
+ const DURABLE_VALUE_ALLOWLIST = new Set([
119
+ 'required', 'optional', 'unknown', 'missing', 'hidden', 'redacted',
120
+ // "correct" is deliberately absent: it opens "correct horse battery
121
+ // staple", the canonical passphrase shape, through the first-word check.
122
+ 'omitted', 'enabled', 'disabled', 'expired', 'invalid',
123
+ 'incorrect', 'rotated', 'changed', 'unchanged', 'unset',
124
+ // Short forms, reachable now that an explicit assignment has no length floor.
125
+ 'true', 'false', 'yes', 'no', 'none', 'null', 'nil', 'empty', 'blank',
126
+ 'n/a', 'na', 'tbd', 'todo', 'set', 'ok',
127
+ ]);
128
+
129
+ // A value already replaced by this module. Allowlisted so masking a text
130
+ // twice yields the same text instead of nesting placeholders.
131
+ const PLACEHOLDER_VALUE = /^‹redacted:[a-z-]+›$/;
132
+
133
+ const TRAILING_PUNCTUATION = /[.,;:!?)\]}>'"–—]+$/;
134
+
135
+ /**
136
+ * Field names that declare a credential in structured data. Matched on the
137
+ * key with separators and case removed, and by SUFFIX, so `DB_PASSWORD`,
138
+ * `apiKey`, `x-api-key` and `client_secret` all count while `cache_key` and
139
+ * `sort_key` do not — `key` alone is not a credential noun. A trailing plural
140
+ * is folded in (`secrets`, `tokens`).
141
+ *
142
+ * A pagination cursor named `next_token` is masked by this rule. That is the
143
+ * intended direction: over-masking a stored copy is recoverable, and the
144
+ * live value the caller passes is never touched.
145
+ */
146
+ const CREDENTIAL_KEY_SUFFIXES = [
147
+ 'password', 'passwd', 'pwd', 'passphrase', 'secret', 'token',
148
+ 'apikey', 'accesstoken', 'authtoken', 'clientsecret', 'refreshtoken',
149
+ 'privatekey', 'credential',
59
150
  ];
60
151
 
61
- const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
152
+ function isCredentialKey(key) {
153
+ if (typeof key !== 'string' || !key) return false;
154
+ const normalized = key.toLowerCase().replace(/[^a-z]/g, '').replace(/s$/, '');
155
+ return CREDENTIAL_KEY_SUFFIXES.some((noun) => normalized.endsWith(noun));
156
+ }
157
+
158
+ function isAllowedDurableValue(value) {
159
+ const bare = String(value).trim().replace(TRAILING_PUNCTUATION, '');
160
+ if (PLACEHOLDER_VALUE.test(bare)) return true;
161
+ const firstWord = bare.split(/\s+/)[0].replace(TRAILING_PUNCTUATION, '');
162
+ return DURABLE_VALUE_ALLOWLIST.has(firstWord.toLowerCase());
163
+ }
62
164
 
63
165
  /**
64
166
  * Detect secrets in `text`. Returns non-overlapping detections ordered by
@@ -75,17 +177,24 @@ function detectSecrets(text) {
75
177
  if (m[0] === '') { rule.re.lastIndex += 1; continue; } // guard zero-width
76
178
  let start = m.index;
77
179
  let value = m[0];
78
- if (rule.group && m[rule.group] != null) {
180
+ // `group` names one capture; `groups` names alternatives (a value can be
181
+ // double-quoted, single-quoted or bare) of which exactly one matches.
182
+ const candidates = rule.groups || (rule.group ? [rule.group] : []);
183
+ const g = candidates.find((n) => m[n] != null);
184
+ if (g != null) {
79
185
  // Exact group span from the `d` flag (m.indices). Fall back to
80
186
  // lastIndexOf (the captured value sits at the TAIL of these matches,
81
187
  // so the last occurrence is the right one) if indices are unavailable.
82
- const span = m.indices && m.indices[rule.group];
188
+ const span = m.indices && m.indices[g];
83
189
  if (span) {
84
- start = span[0]; value = m[rule.group];
190
+ start = span[0]; value = m[g];
85
191
  } else {
86
- const off = m[0].lastIndexOf(m[rule.group]);
87
- if (off >= 0) { start = m.index + off; value = m[rule.group]; }
192
+ const off = m[0].lastIndexOf(m[g]);
193
+ if (off >= 0) { start = m.index + off; value = m[g]; }
88
194
  }
195
+ // Trailing whitespace belongs to the sentence, not the credential.
196
+ const trimmed = value.replace(/\s+$/, '');
197
+ value = trimmed;
89
198
  }
90
199
  hits.push({ rule: rule.name, tier: rule.tier, start, end: start + value.length, value });
91
200
  }
@@ -105,10 +214,14 @@ function detectSecrets(text) {
105
214
  * Redact a text. By default redacts `high` + `medium` tiers in place (low is
106
215
  * flagged, not redacted). Idempotent: the placeholder never re-matches a rule.
107
216
  *
217
+ * Records are content-free: rule, tier and length only. No digest of the
218
+ * value is produced anywhere in this module — an unsalted hash of a guessable
219
+ * secret is a correlation handle for that secret.
220
+ *
108
221
  * @param {string} text
109
222
  * @param {object} [opts]
110
223
  * @param {string[]} [opts.redactTiers] tiers to actually redact (default high+medium)
111
- * @returns {{ text:string, changed:boolean, redacted:Array<{rule,tier,length,sha256}>, flagged:Array<{rule,tier,length,sha256}> }}
224
+ * @returns {{ text:string, changed:boolean, redacted:Array<{rule,tier,length}>, flagged:Array<{rule,tier,length}> }}
112
225
  */
113
226
  function redactText(text, opts = {}) {
114
227
  const redactTiers = new Set(opts.redactTiers || ['high', 'medium']);
@@ -119,7 +232,7 @@ function redactText(text, opts = {}) {
119
232
  let out = text;
120
233
  for (let i = dets.length - 1; i >= 0; i--) {
121
234
  const d = dets[i];
122
- const rec = { rule: d.rule, tier: d.tier, length: d.value.length, sha256: sha256(d.value) };
235
+ const rec = { rule: d.rule, tier: d.tier, length: d.value.length };
123
236
  if (redactTiers.has(d.tier)) {
124
237
  out = out.slice(0, d.start) + PLACEHOLDER(d.rule) + out.slice(d.end);
125
238
  redacted.push(rec);
@@ -131,4 +244,159 @@ function redactText(text, opts = {}) {
131
244
  return { text: out, changed: redacted.length > 0, redacted, flagged };
132
245
  }
133
246
 
134
- module.exports = { detectSecrets, redactText, sha256, RULES, PLACEHOLDER };
247
+ /**
248
+ * Mask a text for DURABLE storage. Unlike redactText (which mirrors the
249
+ * sweep's "low tier is flagged, a human confirms"), this is the pre-write
250
+ * boundary: a low-tier hit is an actual-or-unresolved credential and is masked
251
+ * unless its value is in the narrow allowlist. Callers pass the ORIGINAL text
252
+ * to the live turn and only the returned text to SQLite/FTS.
253
+ *
254
+ * The returned records are content-free — rule/tier only, no value, length or
255
+ * fingerprint — so nothing secret-derived travels with them.
256
+ *
257
+ * @param {string} text
258
+ * @returns {{ text:string, changed:boolean, masked:Array<{rule,tier}>, allowed:Array<{rule,tier}> }}
259
+ */
260
+ function sanitizeForDurableWrite(text) {
261
+ if (typeof text !== 'string' || !text) {
262
+ return { text, changed: false, masked: [], allowed: [] };
263
+ }
264
+ const dets = detectSecrets(text);
265
+ const masked = [];
266
+ const allowed = [];
267
+ // apply right-to-left so earlier indices stay valid
268
+ let out = text;
269
+ for (let i = dets.length - 1; i >= 0; i--) {
270
+ const d = dets[i];
271
+ if (d.tier === 'low' && isAllowedDurableValue(d.value)) {
272
+ allowed.push({ rule: d.rule, tier: d.tier });
273
+ continue;
274
+ }
275
+ out = out.slice(0, d.start) + PLACEHOLDER(d.rule) + out.slice(d.end);
276
+ masked.push({ rule: d.rule, tier: d.tier });
277
+ }
278
+ masked.reverse(); allowed.reverse();
279
+ return { text: out, changed: masked.length > 0, masked, allowed };
280
+ }
281
+
282
+ // A structure deeper than this is malformed rather than legitimate payload,
283
+ // and is dropped instead of walked.
284
+ const MAX_STRUCTURE_DEPTH = 12;
285
+
286
+ const isPlainObject = (v) => {
287
+ if (v === null || typeof v !== 'object') return false;
288
+ const proto = Object.getPrototypeOf(v);
289
+ return proto === Object.prototype || proto === null;
290
+ };
291
+
292
+ const DROP = Symbol('drop');
293
+
294
+ /**
295
+ * Mask every string reachable in a structured payload, returning a new value
296
+ * with the same shape. The caller's object is never mutated, so the live turn
297
+ * keeps its original.
298
+ *
299
+ * Sanitize BEFORE serialization, never after: `JSON.stringify` escapes the
300
+ * quotes around a declared value, and the escaped form no longer looks like a
301
+ * quoted value to the detector — a credential that would be caught in the
302
+ * object survives in the string. Masking the serialized form can also splice
303
+ * across JSON delimiters.
304
+ *
305
+ * Only plain objects and arrays are walked. Anything else that is not a
306
+ * primitive — a Date, a Map, a class instance, a function — is DROPPED rather
307
+ * than passed through, because its serialized form is not something this
308
+ * module can inspect.
309
+ */
310
+ function sanitizeDurableStructured(value) {
311
+ const walk = (v, depth, underCredential) => {
312
+ if (typeof v === 'string') {
313
+ // A structured payload declares the credential in the KEY, so the value
314
+ // carries no `password:` prefix for the text rules to find:
315
+ // `{"password":"hunter2"}` is as much a declaration as
316
+ // `password: hunter2`. The key is the declaration here, and the whole
317
+ // value is the credential.
318
+ //
319
+ // The declaration also covers what is nested under it —
320
+ // `{"password":{"value":"…"}}` and `{"credentials":[…]}` wrap the same
321
+ // claim — so the context travels down to every string leaf. This
322
+ // over-masks a benign sibling under a credential key; that is the
323
+ // chosen direction, because the stored copy is recoverable from the
324
+ // live turn and an unmasked credential is not.
325
+ if (underCredential && !isAllowedDurableValue(v)) {
326
+ return PLACEHOLDER('kv-secret');
327
+ }
328
+ return sanitizeForDurableWrite(v).text;
329
+ }
330
+ // A BigInt has no JSON representation: leaving it in place makes the whole
331
+ // document throw at serialization, which loses the write with no record.
332
+ if (typeof v === 'bigint') return DROP;
333
+ if (v === null || typeof v === 'number' || typeof v === 'boolean') return v;
334
+ if (depth >= MAX_STRUCTURE_DEPTH) return DROP;
335
+ if (Array.isArray(v)) {
336
+ // An array under a credential key inherits it: `{"passwords":["a","b"]}`
337
+ // declares every element.
338
+ return v.map((item) => {
339
+ const walked = walk(item, depth + 1, underCredential);
340
+ return walked === DROP ? null : walked;
341
+ });
342
+ }
343
+ if (isPlainObject(v)) {
344
+ const out = {};
345
+ for (const [k, item] of Object.entries(v)) {
346
+ const walked = walk(item, depth + 1, underCredential || isCredentialKey(k));
347
+ if (walked === DROP || walked === undefined) continue;
348
+ // A key can carry a secret of its own — a payload keyed by a token,
349
+ // or a stringified assignment used as a field name. Only a key with a
350
+ // detectable signal is rewritten; ordinary keys are left exactly as
351
+ // they are, so shapes stay recognizable.
352
+ const safeKey = sanitizeForDurableWrite(k).text;
353
+ if (safeKey !== k && Object.prototype.hasOwnProperty.call(out, safeKey)) continue;
354
+ out[safeKey] = walked;
355
+ }
356
+ return out;
357
+ }
358
+ return DROP;
359
+ };
360
+ const walked = walk(value, 0, false);
361
+ if (walked === DROP || walked === undefined) return isPlainObject(value) ? {} : {};
362
+ return walked;
363
+ }
364
+
365
+ /**
366
+ * Sanitize text that is expected to be a serialized JSON document: parse it,
367
+ * sanitize the structure, and reserialize. A caller that hands over an
368
+ * already-serialized payload gets the same treatment as one that hands over
369
+ * the object — including key-aware masking, which a text pass cannot do.
370
+ *
371
+ * Text that is not a JSON document (a shell command, a plain string) falls
372
+ * back to text masking, so nothing is lost by trying.
373
+ *
374
+ * @returns {{ text:string, changed:boolean }} `changed` compares against a
375
+ * reserialization of the same parse, so reformatting alone never
376
+ * counts as a change — callers key dedupe decisions on it.
377
+ */
378
+ function sanitizeDurableJsonText(text) {
379
+ if (typeof text !== 'string') return { text, changed: false };
380
+ let parsed;
381
+ try { parsed = JSON.parse(text); } catch { return textResult(text); }
382
+ if (parsed === null || typeof parsed !== 'object') return textResult(text);
383
+ const baseline = JSON.stringify(parsed);
384
+ const sanitized = JSON.stringify(sanitizeDurableStructured(parsed));
385
+ return { text: sanitized, changed: sanitized !== baseline };
386
+ }
387
+
388
+ function textResult(text) {
389
+ const res = sanitizeForDurableWrite(text);
390
+ return { text: res.text, changed: res.changed };
391
+ }
392
+
393
+ module.exports = {
394
+ detectSecrets,
395
+ redactText,
396
+ sanitizeForDurableWrite,
397
+ sanitizeDurableStructured,
398
+ sanitizeDurableJsonText,
399
+ RULES,
400
+ PLACEHOLDER,
401
+ DURABLE_VALUE_ALLOWLIST,
402
+ };
@@ -320,7 +320,8 @@ async function send({ bot, method, params, db = null, meta = {}, logger = consol
320
320
  try {
321
321
  db?.logEvent('telegram-html-fallback', {
322
322
  chat_id: chatId, method,
323
- error: redactBotToken(err.message)?.slice(0, 200),
323
+ error_code: err.code || err.name || 'error',
324
+ error_len: err.message?.length ?? 0,
324
325
  });
325
326
  } catch {}
326
327
  const plainParams = { ...p };
@@ -417,7 +418,14 @@ async function send({ bot, method, params, db = null, meta = {}, logger = consol
417
418
  const safe = redactBotToken(err.message);
418
419
  try { db.markOutboundFailed(rowId, safe); }
419
420
  catch (e) { logger.error(`[telegram] markOutboundFailed: ${e.message}`); }
420
- try { db.logEvent('telegram-api-error', { chat_id: chatId, method, error: safe }); }
421
+ try {
422
+ db.logEvent('telegram-api-error', {
423
+ chat_id: chatId,
424
+ method,
425
+ error_code: err.code || err.name || 'error',
426
+ error_len: typeof safe === 'string' ? safe.length : 0,
427
+ });
428
+ }
421
429
  catch (e) { logger.error(`[telegram] logEvent: ${e.message}`); }
422
430
  }
423
431
  throw err;
@@ -216,7 +216,7 @@ function createRichEditor({
216
216
  fallbackKind = 'content-error';
217
217
  logEvent('rich-content-fallback', {
218
218
  chat_id: chatId, bot: botName,
219
- error: 'media-source-changed',
219
+ error_code: 'media-source-changed',
220
220
  });
221
221
  } else if (isRichCapabilityError(err)) {
222
222
  // This edit degrades either way; the latch decides whether every
@@ -227,13 +227,15 @@ function createRichEditor({
227
227
  logEvent('rich-capability-latched', {
228
228
  chat_id: chatId, bot: botName,
229
229
  api_root: stripUrlCreds(getApiRoot() || 'cloud'),
230
- error: redactBotToken(err.message)?.slice(0, 200),
230
+ error_code: err.code || err.name || 'error',
231
+ error_len: err.message?.length ?? 0,
231
232
  });
232
233
  } else {
233
234
  logEvent('rich-capability-strike', {
234
235
  chat_id: chatId, thread_id: threadId, bot: botName,
235
236
  transport: 'edit',
236
- error: redactBotToken(err.message)?.slice(0, 200),
237
+ error_code: err.code || err.name || 'error',
238
+ error_len: err.message?.length ?? 0,
237
239
  });
238
240
  }
239
241
  } else if (isRichContentError(err)) {
@@ -247,7 +249,8 @@ function createRichEditor({
247
249
  mediaContext?.evictCachedBlocks?.(blocks);
248
250
  logEvent('rich-content-fallback', {
249
251
  chat_id: chatId, bot: botName,
250
- error: redactBotToken(err.message)?.slice(0, 200),
252
+ error_code: err.code || err.name || 'error',
253
+ error_len: err.message?.length ?? 0,
251
254
  });
252
255
  } else {
253
256
  // Transient (5xx/timeout/etc) — not ours to reclassify as a
@@ -257,7 +260,8 @@ function createRichEditor({
257
260
  logEvent('telegram-edit-failed', {
258
261
  chat_id: chatId, msg_id: messageId,
259
262
  // Network errors can include the request URL and bot token.
260
- api_error: redactBotToken(err.message)?.slice(0, 200),
263
+ api_error_code: err.code || err.name || 'error',
264
+ error_len: err.message?.length ?? 0,
261
265
  bot: botName,
262
266
  });
263
267
  throw err;
@@ -148,7 +148,7 @@ function createRichSender({
148
148
  chat_id: chatId, thread_id: threadId, bot: botName,
149
149
  transport: 'send',
150
150
  media_count: safeMediaCount(blocks),
151
- error: 'response carried no message_id',
151
+ error_code: 'no-message-id',
152
152
  });
153
153
  // Its own class, not 'content-error': that class carries a side effect
154
154
  // (evicting the cached ids the server rejected), and a response we
@@ -239,7 +239,7 @@ function createRichSender({
239
239
  chat_id: chatId, thread_id: threadId, bot: botName,
240
240
  transport: 'send',
241
241
  media_count: safeMediaCount(blocks),
242
- error: 'media-source-changed',
242
+ error_code: 'media-source-changed',
243
243
  });
244
244
  return 'media-source-changed';
245
245
  }
@@ -251,7 +251,7 @@ function createRichSender({
251
251
  logEvent('rich-capability-strike', {
252
252
  chat_id: chatId, thread_id: threadId, bot: botName,
253
253
  transport: 'send',
254
- error: redactBotToken(errText(err))?.slice(0, 200),
254
+ error_code: err?.code || err?.name || 'error',
255
255
  });
256
256
  }
257
257
  return 'capability';
@@ -273,7 +273,7 @@ function createRichSender({
273
273
  transport: 'send',
274
274
  media_count: safeMediaCount(blocks),
275
275
  limit: limited,
276
- error: redactBotToken(errText(err))?.slice(0, 200),
276
+ error_code: err?.code || err?.name || 'error',
277
277
  });
278
278
  return limited ? 'content-limit' : 'content-error';
279
279
  }
@@ -283,7 +283,7 @@ function createRichSender({
283
283
  logEvent('rich-send-error', {
284
284
  chat_id: chatId, thread_id: threadId, bot: botName,
285
285
  error_class: err?.name || 'Error',
286
- error: redactBotToken(errText(err))?.slice(0, 200),
286
+ error_code: err?.code || err?.name || 'error',
287
287
  });
288
288
  return 'error';
289
289
  }
@@ -293,7 +293,7 @@ function createRichSender({
293
293
  chat_id: chatId, bot: botName,
294
294
  transport: 'send',
295
295
  api_root: stripUrlCreds(getApiRoot() || 'cloud'),
296
- error: redactBotToken(errText(err))?.slice(0, 200),
296
+ error_code: err?.code || err?.name || 'error',
297
297
  });
298
298
  }
299
299
  }
@@ -0,0 +1,13 @@
1
+ -- Remove the secret-derived correlation value from the redaction audit.
2
+ --
3
+ -- secret_redactions.sha256 held an unsalted SHA-256 of the redacted value. It
4
+ -- was intended as "audit without storing the secret", but an unsalted digest
5
+ -- of a guessable value is a correlation handle for that value: anyone holding
6
+ -- a candidate can confirm it appeared, and can join every place it appeared.
7
+ -- The audit keeps what kind of secret was redacted, where and when, which is
8
+ -- what incident review actually reads.
9
+ --
10
+ -- Dropping the column also removes every historical digest already stored, so
11
+ -- no migration of old values is needed or possible.
12
+ DROP INDEX IF EXISTS idx_secret_redactions_sha;
13
+ ALTER TABLE secret_redactions DROP COLUMN sha256;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.38.2",
3
+ "version": "0.38.3",
4
4
  "description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
5
5
  "main": "lib/ipc/client.js",
6
6
  "bin": {
@@ -63,7 +63,7 @@
63
63
  "dependencies": {
64
64
  "@anthropic-ai/claude-agent-sdk": "0.2.123",
65
65
  "@modelcontextprotocol/sdk": "^1.29.0",
66
- "@shumkov/orchestra": "0.10.16",
66
+ "@shumkov/orchestra": "0.10.17",
67
67
  "better-sqlite3": "^12.9.0",
68
68
  "grammy": "^1.42.0",
69
69
  "markdown-it": "^14.1.1",