polygram 0.12.0-rc.41 → 0.12.0-rc.42

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.
@@ -301,10 +301,15 @@ function createChannelsToolDispatcher({
301
301
  * is the safe default.
302
302
  */
303
303
  function buildAllowedRoots({ sessionKey, sessionCwd = null, extraRoots = [] }) {
304
- const roots = [
305
- DEFAULT_ATTACHMENT_BASE,
306
- path.join(DEFAULT_ATTACHMENT_BASE, String(sessionKey || '')),
307
- ];
304
+ const roots = [];
305
+ // SECURITY (review 2026-06-12): allow ONLY this session's own staging subdir,
306
+ // never the shared DEFAULT_ATTACHMENT_BASE parent. All sessions' claude procs
307
+ // run as the same uid, so a base-level root let session A send a file staged
308
+ // under session B (/tmp/polygram-attachments/<B>/…) → cross-chat exfiltration.
309
+ // A falsy sessionKey would collapse path.join(BASE,'') back to BASE, so skip
310
+ // the staging root entirely when it's missing (only sessionCwd/extras remain).
311
+ const sk = String(sessionKey || '');
312
+ if (sk) roots.push(path.join(DEFAULT_ATTACHMENT_BASE, sk));
308
313
  if (sessionCwd) roots.push(sessionCwd);
309
314
  if (Array.isArray(extraRoots)) {
310
315
  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
- if (Array.isArray(args.consumed_turn_ids) && args.consumed_turn_ids.length) {
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
 
@@ -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
- if (!tid) return { scope: chatConfig, threadId: null };
123
- chatConfig.topics = chatConfig.topics || {};
124
- chatConfig.topics[tid] = chatConfig.topics[tid] || {};
125
- return { scope: chatConfig.topics[tid], threadId: tid };
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.41",
3
+ "version": "0.12.0-rc.42",
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": {