polygram 0.12.0-rc.42 → 0.12.0-rc.43

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.
@@ -38,6 +38,13 @@ const os = require('node:os');
38
38
  // additional roots (e.g. a configured uploads dir).
39
39
  const DEFAULT_ATTACHMENT_BASE = path.join(os.tmpdir(), 'polygram-attachments');
40
40
 
41
+ // Review 2026-06-12 hardening: cap files[] per reply so one (possibly
42
+ // prompt-injected) call can't fan out unlimited uploads past the per-call
43
+ // rate limit; cap the per-session owned-message-id set so the edit-ownership
44
+ // tracker can't grow unbounded on a long-lived session.
45
+ const MAX_FILES_PER_REPLY = 10;
46
+ const OWNED_MSG_CAP = 256;
47
+
41
48
  function isPathUnder(child, parent) {
42
49
  const rel = path.relative(parent, child);
43
50
  return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -125,6 +132,23 @@ function createChannelsToolDispatcher({
125
132
  const deliverAgent = processAndDeliverAgentText
126
133
  || require('../telegram/process-agent-reply').processAndDeliverAgentText;
127
134
 
135
+ // Per-session set of message_ids THIS session created (via reply/edit).
136
+ // edit_message may only target an owned bubble — a prompt-injected
137
+ // edit_message can't tamper with a message it didn't send, or another
138
+ // session's bubble (review 2026-06-12). Bounded per session (insertion-order
139
+ // eviction) so a long session can't grow it without limit.
140
+ const ownedMessageIds = new Map(); // sessionKey → Set<number>
141
+ const rememberOwned = (sk, id) => {
142
+ if (id == null || sk == null) return;
143
+ const n = Number(id);
144
+ if (!Number.isFinite(n)) return;
145
+ let set = ownedMessageIds.get(sk);
146
+ if (!set) { set = new Set(); ownedMessageIds.set(sk, set); }
147
+ set.add(n);
148
+ while (set.size > OWNED_MSG_CAP) set.delete(set.values().next().value);
149
+ };
150
+ const ownsMessage = (sk, id) => ownedMessageIds.get(sk)?.has(Number(id)) === true;
151
+
128
152
  return async function channelsToolDispatcher(call) {
129
153
  const { sessionKey, chatId, threadId, toolName, text, files, sourceMsgId, maxOutboundFileBytes, messageId } = call;
130
154
 
@@ -149,10 +173,18 @@ function createChannelsToolDispatcher({
149
173
  if (clean.length > maxChunkLen) {
150
174
  return { ok: false, error: `edit text too long (${clean.length} > ${maxChunkLen}); a message edit is a single bubble — use reply for long content` };
151
175
  }
176
+ // Ownership gate (review 2026-06-12): only edit a bubble THIS session
177
+ // created (got its id from a reply/edit result). Blocks a prompt-injected
178
+ // edit_message from tampering with an arbitrary or cross-session message.
179
+ if (!ownsMessage(sessionKey, messageId)) {
180
+ logger.warn?.(`[channels-tool-dispatcher] ${sessionKey} edit_message DENIED: message_id ${messageId} not owned by this session`);
181
+ return { ok: false, error: `message_id ${messageId} was not created by this session — edit_message can only target a bubble you sent` };
182
+ }
152
183
  try {
153
184
  const params = { chat_id: chatId, message_id: messageId, text: clean };
154
185
  if (threadId) params.message_thread_id = threadId;
155
186
  await send(bot, 'editMessageText', params, { source: 'channels-tool-dispatcher', sessionKey, toolName });
187
+ rememberOwned(sessionKey, messageId); // keep ownership across re-edits
156
188
  return { ok: true, message_id: messageId };
157
189
  } catch (err) {
158
190
  logger.error?.(`[channels-tool-dispatcher] ${sessionKey} edit_message failed: ${err.message}`);
@@ -209,6 +241,13 @@ function createChannelsToolDispatcher({
209
241
  // tolerate the {message_id} object shape too. null for solo sticker/reaction.
210
242
  const firstSent = dr && Array.isArray(dr.sent) ? dr.sent.find(x => x != null) : null;
211
243
  const replyMessageId = (firstSent && typeof firstSent === 'object') ? firstSent.message_id : firstSent;
244
+ // Remember every delivered bubble so claude can edit_message any of them
245
+ // (the ownership gate above) — not just the first one returned.
246
+ if (dr && Array.isArray(dr.sent)) {
247
+ for (const s of dr.sent) {
248
+ rememberOwned(sessionKey, (s && typeof s === 'object') ? s.message_id : s);
249
+ }
250
+ }
212
251
 
213
252
  // File attachments — sent as separate messages AFTER the text.
214
253
  // Photos for image MIMEs, Documents for everything else (matches
@@ -220,12 +259,22 @@ function createChannelsToolDispatcher({
220
259
  // keys, and AWS creds can't leak to Telegram.
221
260
  const failedAttachments = [];
222
261
  if (Array.isArray(files) && files.length > 0) {
262
+ // Cap the per-reply fan-out (review 2026-06-12): a single (possibly
263
+ // injected) call attaching dozens of files would bypass the per-call
264
+ // rate limit. Process the first MAX_FILES_PER_REPLY; surface the rest.
265
+ let toAttach = files;
266
+ if (files.length > MAX_FILES_PER_REPLY) {
267
+ toAttach = files.slice(0, MAX_FILES_PER_REPLY);
268
+ for (const extra of files.slice(MAX_FILES_PER_REPLY)) {
269
+ failedAttachments.push({ path: extra, error: `too many files in one reply (max ${MAX_FILES_PER_REPLY}); send the rest in a follow-up` });
270
+ }
271
+ }
223
272
  const allowedRoots = buildAllowedRoots({
224
273
  sessionKey,
225
274
  sessionCwd: call.sessionCwd,
226
275
  extraRoots: attachmentAllowlist,
227
276
  });
228
- for (const filePath of files) {
277
+ for (const filePath of toAttach) {
229
278
  const check = validateAttachmentPath(filePath, allowedRoots);
230
279
  if (!check.ok) {
231
280
  logger.warn?.(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.12.0-rc.42",
3
+ "version": "0.12.0-rc.43",
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": {