polygram 0.12.7 → 0.12.9

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.
@@ -23,6 +23,9 @@
23
23
  */
24
24
 
25
25
  const DAY_MS = 86_400_000;
26
+ // A few minutes of grace so rows written ~now (between Date.now() capture and the
27
+ // COUNT) don't read as "future". Genuine backward-clock detection is fraction-based.
28
+ const CLOCK_SKEW_TOLERANCE_MS = 60_000;
26
29
 
27
30
  const DEFAULT_POLICY = {
28
31
  enabled: true,
@@ -65,6 +68,25 @@ function resolveRetentionPolicy(config) {
65
68
 
66
69
  /** Fail loud on a misconfigured policy. Called at load and defensively per-run. */
67
70
  function validatePolicy(policy) {
71
+ // Numeric sanity. An unvalidated batchSize<=0 makes batchedDelete spin forever
72
+ // (DELETE LIMIT 0 deletes 0, `0 < 0` is false) and wedges the synchronous
73
+ // daemon — the "a config typo must never take down the bot" promise depends on
74
+ // catching it here, before the loop.
75
+ if (!Number.isInteger(policy.batchSize) || policy.batchSize < 1) {
76
+ throw new Error(`events_retention: batchSize must be a positive integer (got ${policy.batchSize})`);
77
+ }
78
+ if (!Number.isInteger(policy.maxPerKind) || policy.maxPerKind < 1) {
79
+ throw new Error(`events_retention: maxPerKind must be a positive integer (got ${policy.maxPerKind})`);
80
+ }
81
+ if (!(typeof policy.maxDeleteFraction === 'number' && policy.maxDeleteFraction > 0 && policy.maxDeleteFraction <= 1)) {
82
+ throw new Error(`events_retention: maxDeleteFraction must be in (0,1] (got ${policy.maxDeleteFraction})`);
83
+ }
84
+ for (const f of ['diagnosticDays', 'defaultDays']) {
85
+ if (!(typeof policy[f] === 'number' && policy[f] > 0)) {
86
+ throw new Error(`events_retention: ${f} must be a positive number (got ${policy[f]})`);
87
+ }
88
+ }
89
+
68
90
  const diag = policy.diagnosticKinds || [];
69
91
  const keep = policy.keepForeverKinds || [];
70
92
  for (const k of [...diag, ...keep]) {
@@ -98,7 +120,10 @@ function batchedDelete(rawDb, sql, params, batchSize) {
98
120
  for (;;) {
99
121
  const r = stmt.run(...params, batchSize);
100
122
  deleted += r.changes;
101
- if (r.changes < batchSize) break;
123
+ // `=== 0` is the belt-and-suspenders against a pathological LIMIT (e.g. a
124
+ // negative LIMIT = unlimited, which would otherwise loop on the empty set);
125
+ // validatePolicy already rejects batchSize<1, so this is defense-in-depth.
126
+ if (r.changes < batchSize || r.changes === 0) break;
102
127
  }
103
128
  return deleted;
104
129
  }
@@ -117,37 +142,57 @@ function pruneEvents(rawDb, now, policy) {
117
142
  const diagSet = new Set(policy.diagnosticKinds);
118
143
  const keepSet = new Set(policy.keepForeverKinds);
119
144
 
120
- const before = rawDb.prepare('SELECT count(*) c, max(ts) mx FROM events').get();
121
- const totalBefore = before.c;
145
+ const totalBefore = rawDb.prepare('SELECT count(*) c FROM events').get().c;
122
146
  if (totalBefore === 0) {
123
- return { deleted: { default: 0, diagnostic: 0, cap: 0, total: 0 }, before: 0, after: 0 };
147
+ return policy.dryRun
148
+ ? { dryRun: true, preview: { default: 0, diagnostic: 0, cap: 0, total: 0 }, before: 0 }
149
+ : { deleted: { default: 0, diagnostic: 0, cap: 0, total: 0 }, before: 0, after: 0 };
124
150
  }
125
151
 
126
- // Clock-backward guard: newest row is in the future relative to `now` ⇒ the
127
- // system clock can't be trusted, don't delete on it.
128
- if (before.mx != null && now < before.mx) {
129
- return { skipped: true, reason: `clock-backward (now ${now} < max ts ${before.mx})` };
152
+ // Clock-suspect guard. A genuine backward clock makes a LARGE fraction of rows
153
+ // read as "future" relative to `now` pruning under it would delete recent
154
+ // data. But a SINGLE skewed/imported future row must NOT disable pruning, so
155
+ // this is fraction-based, not max(ts)-based (review finding #3: one outlier
156
+ // poisoning MAX(ts) silently stopped all pruning).
157
+ const future = rawDb.prepare('SELECT count(*) c FROM events WHERE ts > ?').get(now + CLOCK_SKEW_TOLERANCE_MS).c;
158
+ if (future > 0 && future / totalBefore > policy.maxDeleteFraction) {
159
+ return { skipped: true, reason: `clock-suspect (${future}/${totalBefore} rows future-dated > ${policy.maxDeleteFraction})` };
130
160
  }
131
161
 
132
162
  const diagCut = now - policy.diagnosticDays * DAY_MS;
133
163
  const defCut = now - policy.defaultDays * DAY_MS;
134
164
 
135
- // Default-bucket predicate: old AND not diagnostic AND not keep-forever.
136
- // Explicit ?-placeholders — better-sqlite3 does NOT expand a JS array from one
137
- // param, and `NOT IN (…, NULL)` is a 3-valued-logic trap. validatePolicy
138
- // already guarantees no NULL members.
165
+ // Default-bucket predicate (for the actual bulk delete): old AND not diagnostic
166
+ // AND not keep-forever. Explicit ?-placeholders — better-sqlite3 does NOT expand
167
+ // a JS array from one param, and `NOT IN (…, NULL)` is a 3-valued-logic trap;
168
+ // validatePolicy already guarantees no NULL members.
139
169
  const excluded = [...diagSet, ...keepSet];
140
170
  const ph = excluded.map(() => '?').join(',');
141
171
  const defWhere = `ts < ?${excluded.length ? ` AND kind NOT IN (${ph})` : ''}`;
142
172
 
143
173
  // ---- estimate (drives dryRun + the mass-delete guard) ----
144
- const estDefault = rawDb.prepare(`SELECT count(*) c FROM events WHERE ${defWhere}`).get(defCut, ...excluded).c;
145
- let estDiag = 0;
146
- const diagCountStmt = rawDb.prepare('SELECT count(*) c FROM events WHERE kind = ? AND ts < ?');
147
- for (const k of diagSet) estDiag += diagCountStmt.get(k, diagCut).c;
174
+ // Per-kind so the cap estimate counts SURVIVORS after the time-delete, not raw
175
+ // counts. Otherwise a kind that is both old AND over-cap is counted twice
176
+ // (time bucket + cap bucket), inflating estTotal past the real delete count and
177
+ // tripping the mass-delete guard against the exact high-volume prune it exists
178
+ // for — self-defeating (review finding #1). Each row is counted at most once.
179
+ const cntOlder = rawDb.prepare('SELECT count(*) c FROM events WHERE kind = ? AND ts < ?');
148
180
  const kinds = rawDb.prepare('SELECT kind, count(*) c FROM events GROUP BY kind').all();
181
+ let estDefault = 0;
182
+ let estDiag = 0;
149
183
  let estCap = 0;
150
- for (const { c } of kinds) if (c > policy.maxPerKind) estCap += c - policy.maxPerKind;
184
+ for (const { kind, c } of kinds) {
185
+ let timeDel = 0;
186
+ if (keepSet.has(kind)) {
187
+ timeDel = 0; // keep-forever: no time delete
188
+ } else if (diagSet.has(kind)) {
189
+ timeDel = cntOlder.get(kind, diagCut).c; estDiag += timeDel;
190
+ } else {
191
+ timeDel = cntOlder.get(kind, defCut).c; estDefault += timeDel;
192
+ }
193
+ const survivors = c - timeDel;
194
+ if (survivors > policy.maxPerKind) estCap += survivors - policy.maxPerKind;
195
+ }
151
196
  const estTotal = estDefault + estDiag + estCap;
152
197
 
153
198
  // dryRun returns the preview regardless of the mass-delete guard (you want to
@@ -190,13 +190,22 @@ function createStore(rawDb, now = () => Date.now()) {
190
190
  return { ok: false, reason: 'wrong-chat' };
191
191
  }
192
192
 
193
+ // Every pairing MUST be chat-scoped. A chat_id=NULL pairing bypasses
194
+ // requireMention in EVERY group the bot is in — the all-chats footgun that
195
+ // let a colleague (Lin) trigger shumabit in the UMI working group without a
196
+ // mention (2026-06-16). Scope to the code's chat if it was issued scoped,
197
+ // else to the chat where it's redeemed. If neither exists, refuse (and DON'T
198
+ // consume the code) rather than create a global pairing.
199
+ const pairChatId = row.chat_id || (chat_id != null ? String(chat_id) : null);
200
+ if (!pairChatId) return { ok: false, reason: 'no-chat-scope' };
201
+
193
202
  const tx = rawDb.transaction(() => {
194
203
  const upd = markCodeUsedStmt.run({ code: norm, user_id: claimer_user_id, ts: now() });
195
204
  if (upd.changes === 0) throw new Error('race: code claimed by another user');
196
205
  insertPairingStmt.run({
197
206
  bot_name: row.bot_name,
198
207
  user_id: claimer_user_id,
199
- chat_id: row.chat_id || null,
208
+ chat_id: pairChatId,
200
209
  granted_ts: now(),
201
210
  granted_by_user_id: row.issued_by_user_id,
202
211
  note: row.note,
@@ -211,7 +220,7 @@ function createStore(rawDb, now = () => Date.now()) {
211
220
  return {
212
221
  ok: true,
213
222
  bot_name: row.bot_name,
214
- chat_id: row.chat_id,
223
+ chat_id: pairChatId,
215
224
  scope: row.scope,
216
225
  note: row.note,
217
226
  };
@@ -286,7 +286,7 @@ function createSlashCommands({
286
286
  chat_id: out.chat_id, note: out.note,
287
287
  });
288
288
  const ttlLabel = args.ttl || '10m';
289
- const chatLabel = out.chat_id ? `chat ${out.chat_id}` : 'any chat';
289
+ const chatLabel = out.chat_id ? `chat ${out.chat_id}` : 'the chat where it is redeemed';
290
290
  await sendReply(
291
291
  `Code: ${out.code}\nexpires: ${ttlLabel}\nscope: ${out.scope} (${chatLabel})${out.note ? `\nnote: ${out.note}` : ''}\n\nShare with user:\n/pair ${out.code}`,
292
292
  );
@@ -341,7 +341,7 @@ function createSlashCommands({
341
341
  ok: res.ok, reason: res.reason,
342
342
  });
343
343
  if (res.ok) {
344
- const chatLabel = res.chat_id ? `chat ${res.chat_id}` : `every chat ${botName} is in`;
344
+ const chatLabel = res.chat_id ? `chat ${res.chat_id}` : 'this chat';
345
345
  await sendReply(`Paired. You can use me in ${chatLabel}.${res.note ? `\n(${res.note})` : ''}`);
346
346
  return true;
347
347
  }
@@ -57,6 +57,19 @@ function formatRow(row) {
57
57
  return `[${ts}] ${who}: ${prefix}${text}`;
58
58
  }
59
59
 
60
+ /**
61
+ * The `<polygram-history …>` opening tag. All string attribute values are
62
+ * xmlEscaped — defense-in-depth, matching lib/prompt.js's convention. Today
63
+ * chat_id/thread_id are Telegram integers and since is a constant ('7d'), so
64
+ * this isn't currently exploitable, but the #10 body-escape fix shouldn't leave
65
+ * the attribute axis as the one unescaped breakout sink. `count` is rows.length
66
+ * (a number) — no escape needed.
67
+ */
68
+ function openTag(chatId, threadId, count, since) {
69
+ const t = threadId ? ` thread_id="${xmlEscape(threadId)}"` : '';
70
+ return `<polygram-history chat_id="${xmlEscape(chatId)}"${t} preloaded="${count}" since="${xmlEscape(since)}">`;
71
+ }
72
+
60
73
  /**
61
74
  * Build the SessionStart hook callback.
62
75
  *
@@ -122,7 +135,7 @@ function makeSessionStartHook({
122
135
  const lines = rows.map(formatRow).join('\n');
123
136
 
124
137
  const additionalContext = [
125
- `<polygram-history chat_id="${chatId}"${threadId ? ` thread_id="${threadId}"` : ''} preloaded="${rows.length}" since="${since}">`,
138
+ openTag(chatId, threadId, rows.length, since),
126
139
  lines,
127
140
  `</polygram-history>`,
128
141
  '',
@@ -208,9 +221,8 @@ function buildHistoryBlock({
208
221
  }
209
222
  if (rows.length === 0) return '';
210
223
  const lines = rows.map(formatRow).join('\n');
211
- const attrs = `chat_id="${chatId}"${threadId ? ` thread_id="${threadId}"` : ''} preloaded="${rows.length}" since="${since}"`;
212
224
  return [
213
- `<polygram-history ${attrs}>`,
225
+ openTag(chatId, threadId, rows.length, since),
214
226
  lines,
215
227
  `</polygram-history>`,
216
228
  '',
@@ -228,6 +240,7 @@ module.exports = {
228
240
  buildHistoryBlock,
229
241
  // Internals for tests
230
242
  _formatRow: formatRow,
243
+ _openTag: openTag,
231
244
  DEFAULT_PRELOAD_LIMIT,
232
245
  DEFAULT_PRELOAD_SINCE,
233
246
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.12.7",
3
+ "version": "0.12.9",
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": {
@@ -21,7 +21,14 @@ const fs = require('fs');
21
21
  const path = require('path');
22
22
  const Database = require('better-sqlite3');
23
23
 
24
- const history = require('../lib/history');
24
+ // The history helpers live at the PACKAGE ROOT lib/ (one source of truth, shared
25
+ // with the daemon's history-preload) — NOT a skill-local copy. The old
26
+ // `../lib/history` pointed at skills/history/lib/history.js, which has never
27
+ // existed in the repo or the published package, so EVERY `query.js` invocation
28
+ // crashed with MODULE_NOT_FOUND (the history skill was entirely non-functional;
29
+ // found in the 0.12.5–0.12.7 ultra-review). `../../../lib` = <pkg-root>/lib in
30
+ // both the repo and the npm install (which ships skills/ + lib/ as siblings).
31
+ const history = require('../../../lib/history');
25
32
 
26
33
  const POLYGRAM_DIR = process.env.POLYGRAM_DIR || path.resolve(__dirname, '../../../../polygram');
27
34
  const CONFIG_PATH = process.env.POLYGRAM_CONFIG || path.join(POLYGRAM_DIR, 'config.json');
@@ -281,4 +288,8 @@ function main() {
281
288
  }
282
289
  }
283
290
 
284
- main();
291
+ // Exported for tests (the #4 scope-derivation security boundary needs a
292
+ // regression pin). Only auto-run when invoked as the CLI, not when require()'d.
293
+ module.exports = { deriveBotScope, resolveDbPaths, loadConfig };
294
+
295
+ if (require.main === module) main();