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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/approvals/store.js +45 -7
- package/lib/codex/runtime-controller.js +107 -1
- package/lib/db/event-detail-schema.js +523 -0
- package/lib/db/pairings.js +9 -1
- package/lib/db/secret-sweep.js +6 -3
- package/lib/db/sessions.js +2 -2
- package/lib/db.js +308 -49
- package/lib/handlers/abort.js +20 -3
- package/lib/handlers/approvals.js +2 -2
- package/lib/handlers/config-callback.js +7 -7
- package/lib/handlers/dispatcher.js +11 -9
- package/lib/handlers/questions.js +546 -45
- package/lib/handlers/slash-commands.js +54 -13
- package/lib/handlers/voice.js +1 -1
- package/lib/ops/clean-shutdown.js +5 -0
- package/lib/questions/questions.js +51 -0
- package/lib/questions/store.js +61 -5
- package/lib/rewind/execute.js +12 -2
- package/lib/rewind/rewind.js +1 -1
- package/lib/sdk/build-options.js +2 -2
- package/lib/sdk/callbacks.js +14 -9
- package/lib/secret-detect.js +280 -12
- package/lib/telegram/api.js +10 -2
- package/lib/telegram/rich-edit.js +9 -5
- package/lib/telegram/rich-send.js +6 -6
- package/migrations/020-drop-secret-fingerprint.sql +13 -0
- package/package.json +2 -2
- package/polygram.js +82 -45
package/lib/secret-detect.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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[
|
|
188
|
+
const span = m.indices && m.indices[g];
|
|
83
189
|
if (span) {
|
|
84
|
-
start = span[0]; value = m[
|
|
190
|
+
start = span[0]; value = m[g];
|
|
85
191
|
} else {
|
|
86
|
-
const off = m[0].lastIndexOf(m[
|
|
87
|
-
if (off >= 0) { start = m.index + off; value = m[
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
};
|
package/lib/telegram/api.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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",
|