polygram 0.12.0-rc.41 → 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
|
|
277
|
+
for (const filePath of toAttach) {
|
|
229
278
|
const check = validateAttachmentPath(filePath, allowedRoots);
|
|
230
279
|
if (!check.ok) {
|
|
231
280
|
logger.warn?.(
|
|
@@ -301,10 +350,15 @@ function createChannelsToolDispatcher({
|
|
|
301
350
|
* is the safe default.
|
|
302
351
|
*/
|
|
303
352
|
function buildAllowedRoots({ sessionKey, sessionCwd = null, extraRoots = [] }) {
|
|
304
|
-
const roots = [
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
353
|
+
const roots = [];
|
|
354
|
+
// SECURITY (review 2026-06-12): allow ONLY this session's own staging subdir,
|
|
355
|
+
// never the shared DEFAULT_ATTACHMENT_BASE parent. All sessions' claude procs
|
|
356
|
+
// run as the same uid, so a base-level root let session A send a file staged
|
|
357
|
+
// under session B (/tmp/polygram-attachments/<B>/…) → cross-chat exfiltration.
|
|
358
|
+
// A falsy sessionKey would collapse path.join(BASE,'') back to BASE, so skip
|
|
359
|
+
// the staging root entirely when it's missing (only sessionCwd/extras remain).
|
|
360
|
+
const sk = String(sessionKey || '');
|
|
361
|
+
if (sk) roots.push(path.join(DEFAULT_ATTACHMENT_BASE, sk));
|
|
308
362
|
if (sessionCwd) roots.push(sessionCwd);
|
|
309
363
|
if (Array.isArray(extraRoots)) {
|
|
310
364
|
for (const r of extraRoots) {
|
|
@@ -1150,9 +1150,17 @@ class CliProcess extends Process {
|
|
|
1150
1150
|
// acknowledges every <channel> message this reply covers (incl. folds the
|
|
1151
1151
|
// incidental turn_id echo can't express; the reply schema carries ONE
|
|
1152
1152
|
// turn_id). Acked entries can never be declared dropped.
|
|
1153
|
-
|
|
1153
|
+
//
|
|
1154
|
+
// SECURITY (review 2026-06-12): gate the ack on chat_id matching this
|
|
1155
|
+
// session. The chat_id check lives further down (after dedup/rate-limit);
|
|
1156
|
+
// without this guard a reply carrying a FOREIGN chat_id but naming the live
|
|
1157
|
+
// turn here would mark it resolved/_consumedAcked + arm the finalizer —
|
|
1158
|
+
// "delivered" though nothing reached this chat. The actual reject still
|
|
1159
|
+
// happens at the chat_id guard below.
|
|
1160
|
+
const chatIdMatches = this.chatId == null || String(args.chat_id) === String(this.chatId);
|
|
1161
|
+
if (chatIdMatches && Array.isArray(args.consumed_turn_ids) && args.consumed_turn_ids.length) {
|
|
1154
1162
|
this._ledgerAckConsumed(args.consumed_turn_ids.filter((x) => typeof x === 'string'));
|
|
1155
|
-
} else if (msg.name === 'reply' && 'consumed_turn_ids' in args) {
|
|
1163
|
+
} else if (chatIdMatches && msg.name === 'reply' && 'consumed_turn_ids' in args) {
|
|
1156
1164
|
this._lastAckFieldAt = Date.now(); // field present but empty — contract observed
|
|
1157
1165
|
}
|
|
1158
1166
|
|
|
@@ -2192,9 +2200,19 @@ class CliProcess extends Process {
|
|
|
2192
2200
|
// "I was interrupted" message. If it doesn't (5s grace), resolve pending
|
|
2193
2201
|
// turns with subtype 'interrupted' instead of letting them wait the full
|
|
2194
2202
|
// 10-min hardTimer.
|
|
2203
|
+
//
|
|
2204
|
+
// C4 BLOCKER (review 2026-06-12): SNAPSHOT the turns that were in flight at
|
|
2205
|
+
// C-c time and resolve ONLY those. The cancelled turn often finalizes
|
|
2206
|
+
// cleanly DURING the grace (claude acks the C-c) and the user then starts a
|
|
2207
|
+
// NEW turn — the "stop, then redirect" flow cheap-cancel exists for. Without
|
|
2208
|
+
// the snapshot the stale grace iterated pendingTurns LIVE and silently
|
|
2209
|
+
// resolved that fresh turn as 'interrupted' (lost). send() doesn't clear the
|
|
2210
|
+
// grace, so the snapshot is the fix.
|
|
2211
|
+
const interruptedTurnIds = new Set(this.pendingTurns.keys());
|
|
2195
2212
|
this._interruptGraceTimer = setTimeout(() => {
|
|
2196
2213
|
let resolvedAny = false;
|
|
2197
2214
|
for (const [turnId, pending] of this.pendingTurns) {
|
|
2215
|
+
if (!interruptedTurnIds.has(turnId)) continue; // only the turns in flight at C-c
|
|
2198
2216
|
// Synthesize an interrupted resolution: empty text, 'interrupted' subtype.
|
|
2199
2217
|
// Cancel-cheap C3: clear ALL per-pending machinery (mirrors
|
|
2200
2218
|
// _finalizeTurn) — stray timers/listeners on the kept-warm proc are
|
|
@@ -3062,6 +3080,12 @@ class CliProcess extends Process {
|
|
|
3062
3080
|
injectUserMessage({ content, priority = 'next', shouldQuery, msgId, source = 'inject' } = {}) {
|
|
3063
3081
|
if (this.closed) return false;
|
|
3064
3082
|
if (!this.inFlight) return false; // base contract: no live turn → caller falls through
|
|
3083
|
+
// C5 (review 2026-06-12): a cancel is in flight (interrupt grace armed) —
|
|
3084
|
+
// inFlight is still true until the grace fires, but merging a follow-up into
|
|
3085
|
+
// work the user just stopped is wrong AND leaks a fresh 'written' ledger
|
|
3086
|
+
// entry the cancel-loop already passed (later re-delivery). Refuse so the
|
|
3087
|
+
// caller queues it as a fresh primary turn instead.
|
|
3088
|
+
if (this._interruptGraceTimer) return false;
|
|
3065
3089
|
if (!this.bridgeReady) return false;
|
|
3066
3090
|
if (typeof content !== 'string' || !content) return false;
|
|
3067
3091
|
|
package/lib/session-key.js
CHANGED
|
@@ -119,10 +119,19 @@ function getTopicConfig(chatConfig, threadId) {
|
|
|
119
119
|
*/
|
|
120
120
|
function getConfigWriteScope(chatConfig, threadId) {
|
|
121
121
|
const tid = (threadId == null || threadId === '') ? null : String(threadId);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
122
|
+
// Mirror getSessionKey's isolation rule: a per-topic override only takes
|
|
123
|
+
// effect when isolateTopics === true (otherwise every topic shares the
|
|
124
|
+
// chatId-keyed session and topics[tid].model is silently ignored — the
|
|
125
|
+
// 2026-06-12 review found the topic-scope fix re-broke /model on the DEFAULT
|
|
126
|
+
// non-isolated chats and made the card lie). So write the topic scope ONLY
|
|
127
|
+
// when isolated; otherwise write the chat root (the session that actually
|
|
128
|
+
// runs), and report threadId:null so the audit row reflects chat-level reach.
|
|
129
|
+
if (tid && chatConfig?.isolateTopics === true) {
|
|
130
|
+
chatConfig.topics = chatConfig.topics || {};
|
|
131
|
+
chatConfig.topics[tid] = chatConfig.topics[tid] || {};
|
|
132
|
+
return { scope: chatConfig.topics[tid], threadId: tid };
|
|
133
|
+
}
|
|
134
|
+
return { scope: chatConfig, threadId: null };
|
|
126
135
|
}
|
|
127
136
|
|
|
128
137
|
module.exports = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.12.0-rc.
|
|
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": {
|