claude-threads 1.16.0 → 1.16.1

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/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.16.1] - 2026-05-22
11
+
12
+ ### Security
13
+ - **Fail-closed authorization on the user-driven paths that invoke Claude.** A confirmed bypass let an unauthorized Slack user (not in `allowedUsers`, non-empty allowlist) reach Claude: the bot posted both the "not authorized" warning and a real answer in the same thread. Rather than chase the one leaking caller, authorization is now deny-by-default at the functions that spawn or message Claude on a user's behalf: `startSession`, `sendFollowUp`, and `resumePausedSession`, plus the resume-from-reaction path. A single helper, `isAuthorizedForSession`, encodes every tier (global allowlist, empty-allowlist allow-all, per-session owner and invited users) and refuses a missing, empty, or `unknown` username. Every user-facing path now routes through that one helper instead of duplicating the check. The biggest gap was message-driven resume, which ran purely from persisted state with no identity check; it now takes a `username` and verifies it against the persisted session allowlist before resuming. The two system-triggered resume paths (bulk restore after a bot restart) stay ungated by design, since they carry no user identity. Legitimate username-less follow-ups (passthrough slash commands like `/context`, already authorized upstream) pass an explicit `system` flag so they are not caught. The mid-session approval flow is untouched: it still adds approved users to the session allowlist, so the check passes once approval is granted. (#388)
14
+ - **hono 4.12.18 → 4.12.21** picks up four upstream security fixes: GHSA-2gcr-mfcq-wcc3 (`app.mount()` stripped the mount prefix from the raw, undecoded pathname, so percent-encoded paths could be cut at the wrong offset and reach a sub-app with the wrong path), GHSA-xrhx-7g5j-rcj5 (`hono/ip-restriction` compared IPs by string equality, so non-canonical IPv6 forms such as compressed or hex IPv4-mapped addresses slipped past static deny rules), GHSA-3hrh-pfw6-9m5x (the cookie helper didn't sanitize `sameSite`/`priority`, allowing Set-Cookie injection via `;`, `\r`, `\n`), and GHSA-f577-qrjj-4474 (`hono/jwt` accepted any two-part Authorization header regardless of scheme, not just `Bearer`). claude-threads reaches hono through `@hono/node-server` on the inbound webhook surface, so the mount-prefix and Set-Cookie fixes are the relevant ones. (#386)
15
+
16
+ ### Changed
17
+ - **Production deps** bumped: `@hono/node-server` 2.0.2 → 2.0.3 (preserves headers mutated after raw Response construction), `express-rate-limit` 8.5.1 → 8.5.2 (simplified IPv6 key generation). Lockfile-only. (#386)
18
+ - **Dev deps** bumped: `@types/bun` 1.3.13 → 1.3.14, `@types/node` 25.7.0 → 25.9.1, `@types/react` 19.2.14 → 19.2.15, `eslint` 10.3.0 → 10.4.0 (adds `includeIgnoreFile()` and a `for-direction` sequence-expression check, both additive), `lint-staged` 17.0.4 → 17.0.5, `typescript-eslint` 8.59.3 → 8.59.4 (fixes a `no-floating-promises` stack overflow on recursive types). Lockfile-only. (#385)
19
+
20
+ ### Fixed
21
+ - **Multiple pasted screenshots no longer silently vanish.** When you paste several clipboard images into one chat message, the platforms hand them all the same filename (`image.png`). The save path wrote each one with the `wx` (`O_EXCL`) flag, so the second and later files hit `EEXIST`, got caught, and were reported as a download failure, so they never reached Claude. Saves now dedupe filenames within a single message, inserting a numeric suffix before the extension (`image.png`, `image_1.png`, `image_2.png`); files without an extension get `report`, `report_1`, and so on. The unique name is resolved before the write, so the `wx` flag still does its job against symlink races. (#387)
22
+ - **Inbound attachments are no longer capped at 100 MB.** A hard 100 MB ceiling on incoming files meant anything larger was skipped with a "too large" warning, which surprised people sharing big assets. The cap is gone; an attachment of any reported size is downloaded and written to disk. One tradeoff worth knowing: `downloadFile` still buffers the whole file in memory via `arrayBuffer()`, so a very large attachment is held in RAM while it's written. Streaming that download is a separate change. (#387)
23
+
10
24
  ## [1.16.0] - 2026-05-14
11
25
 
12
26
  ### Added
package/dist/index.js CHANGED
@@ -50355,6 +50355,24 @@ function sanitizeFilename(name) {
50355
50355
  }
50356
50356
  return cleaned;
50357
50357
  }
50358
+ function dedupeFilename(name, used) {
50359
+ if (!used.has(name)) {
50360
+ used.add(name);
50361
+ return name;
50362
+ }
50363
+ const lastDot = name.lastIndexOf(".");
50364
+ const hasExt = lastDot > 0;
50365
+ const stem = hasExt ? name.slice(0, lastDot) : name;
50366
+ const ext = hasExt ? name.slice(lastDot) : "";
50367
+ let counter = 1;
50368
+ let candidate = `${stem}_${counter}${ext}`;
50369
+ while (used.has(candidate)) {
50370
+ counter += 1;
50371
+ candidate = `${stem}_${counter}${ext}`;
50372
+ }
50373
+ used.add(candidate);
50374
+ return candidate;
50375
+ }
50358
50376
  function formatBytes(bytes) {
50359
50377
  if (bytes < 1024)
50360
50378
  return `${bytes} B`;
@@ -53585,6 +53603,18 @@ function getSessionStatus(session) {
53585
53603
  return "idle";
53586
53604
  }
53587
53605
 
53606
+ // src/session/authorization.ts
53607
+ function isAuthorizedForSession(check) {
53608
+ const { username, platform, sessionAllowedUsers } = check;
53609
+ if (!username || username === "unknown") {
53610
+ return false;
53611
+ }
53612
+ if (platform.isUserAllowed(username)) {
53613
+ return true;
53614
+ }
53615
+ return sessionAllowedUsers?.has(username) ?? false;
53616
+ }
53617
+
53588
53618
  // src/claude/cli.ts
53589
53619
  init_spawn();
53590
53620
  init_logger();
@@ -54818,7 +54848,7 @@ function createPassthroughHandler(slashCommand) {
54818
54848
  return { handled: false };
54819
54849
  }
54820
54850
  if (ctx.isAllowed) {
54821
- await ctx.sessionManager.sendFollowUp(ctx.threadId, `/${slashCommand}`);
54851
+ await ctx.sessionManager.sendFollowUp(ctx.threadId, `/${slashCommand}`, undefined, undefined, undefined, { system: true });
54822
54852
  }
54823
54853
  return { handled: true };
54824
54854
  };
@@ -54866,7 +54896,7 @@ async function handleDynamicSlashCommand(command, args, ctx) {
54866
54896
  }
54867
54897
  if (ctx.isAllowed) {
54868
54898
  const fullCommand = args ? `/${command} ${args}` : `/${command}`;
54869
- await ctx.sessionManager.sendFollowUp(ctx.threadId, fullCommand);
54899
+ await ctx.sessionManager.sendFollowUp(ctx.threadId, fullCommand, undefined, undefined, undefined, { system: true });
54870
54900
  }
54871
54901
  return { handled: true };
54872
54902
  }
@@ -55387,7 +55417,6 @@ import { lstat, mkdir as mkdir2, mkdtemp, rm as rm2, writeFile as writeFile2 } f
55387
55417
  import { tmpdir as tmpdir2 } from "os";
55388
55418
  import { join as join9 } from "path";
55389
55419
  var log18 = createLogger("streaming");
55390
- var MAX_UPLOAD_SIZE = 100 * 1024 * 1024;
55391
55420
  var UPLOAD_ROOT_DIR = "claude-threads-uploads";
55392
55421
  function safeIdSegment(id) {
55393
55422
  return id.replace(/[^A-Za-z0-9._-]/g, "_");
@@ -55427,18 +55456,11 @@ async function saveFilesToUploadDir(platform, uploadDir, files, debug = false) {
55427
55456
  return { saved, skipped };
55428
55457
  }
55429
55458
  const messageDir = await mkdtemp(join9(uploadDir, `${Date.now().toString(36)}-`));
55459
+ const usedNames = new Set;
55430
55460
  for (const file of files) {
55431
- if (file.size > MAX_UPLOAD_SIZE) {
55432
- skipped.push({
55433
- name: file.name,
55434
- reason: `File too large (${formatBytes(file.size)} > ${formatBytes(MAX_UPLOAD_SIZE)} limit)`,
55435
- suggestion: "Split the file or share it via an external link"
55436
- });
55437
- continue;
55438
- }
55439
55461
  try {
55440
55462
  const buffer = await platform.downloadFile(file.id);
55441
- const safeName = sanitizeFilename(file.name);
55463
+ const safeName = dedupeFilename(sanitizeFilename(file.name), usedNames);
55442
55464
  const absolutePath = join9(messageDir, safeName);
55443
55465
  await writeFile2(absolutePath, buffer, { mode: 384, flag: "wx" });
55444
55466
  saved.push({
@@ -66728,6 +66750,10 @@ async function startSession(options, username, displayName, replyToPostId, platf
66728
66750
  if (!platform) {
66729
66751
  throw new Error(`Platform '${platformId}' not found. Call addPlatform() first.`);
66730
66752
  }
66753
+ if (!isAuthorizedForSession({ username, platform, sessionAllowedUsers: undefined })) {
66754
+ log30.warn(`auth.denied.startSession: @${username || "unknown"} not authorized to start session in ${threadId.substring(0, 8)}...`);
66755
+ return;
66756
+ }
66731
66757
  const activeOrPending = ctx.state.sessions.size + pendingStartsCount;
66732
66758
  if (activeOrPending >= ctx.config.maxSessions) {
66733
66759
  const formatter2 = platform.getFormatter();
@@ -67095,9 +67121,15 @@ ${failFormatter.formatItalic("Your previous conversation context is preserved, b
67095
67121
  await ctx.ops.updateStickyMessage();
67096
67122
  }
67097
67123
  }
67098
- async function sendFollowUp(session, message, files, ctx, username, displayName) {
67124
+ async function sendFollowUp(session, message, files, ctx, username, displayName, options) {
67099
67125
  if (!session.claude.isRunning())
67100
67126
  return;
67127
+ if (!options?.system) {
67128
+ if (!isAuthorizedForSession({ username, platform: session.platform, sessionAllowedUsers: session.sessionAllowedUsers })) {
67129
+ sessionLog6(session).warn(`auth.denied.sendFollowUp: @${username || "unknown"} not authorized`);
67130
+ return;
67131
+ }
67132
+ }
67101
67133
  if (session.needsContextPromptOnNextMessage) {
67102
67134
  session.needsContextPromptOnNextMessage = false;
67103
67135
  await session.messageManager?.prepareForUserMessage();
@@ -67120,7 +67152,7 @@ async function sendFollowUp(session, message, files, ctx, username, displayName)
67120
67152
  session.messageCount++;
67121
67153
  await session.messageManager.handleUserMessage(messageToSend, files, username, displayName);
67122
67154
  }
67123
- async function resumePausedSession(threadId, message, files, ctx) {
67155
+ async function resumePausedSession(threadId, message, files, ctx, username) {
67124
67156
  const persisted = ctx.state.sessionStore.load();
67125
67157
  const state = findPersistedByThreadId(persisted, threadId);
67126
67158
  if (!state) {
@@ -67128,6 +67160,16 @@ async function resumePausedSession(threadId, message, files, ctx) {
67128
67160
  return;
67129
67161
  }
67130
67162
  const shortId = threadId.substring(0, 8);
67163
+ const platform = ctx.state.platforms.get(state.platformId);
67164
+ if (!platform) {
67165
+ log30.warn(`auth.denied.resume: platform '${state.platformId}' not found for ${shortId}...`);
67166
+ return;
67167
+ }
67168
+ const sessionAllowedUsers = new Set(state.sessionAllowedUsers || [state.startedBy].filter(Boolean));
67169
+ if (!isAuthorizedForSession({ username, platform, sessionAllowedUsers })) {
67170
+ log30.warn(`auth.denied.resume: @${username || "unknown"} not authorized to resume ${shortId}...`);
67171
+ return;
67172
+ }
67131
67173
  log30.info(`\uD83D\uDD04 Resuming paused session ${shortId}... for new message`);
67132
67174
  await resumeSession(state, ctx);
67133
67175
  const session = ctx.ops.findSessionByThreadId(threadId);
@@ -67645,9 +67687,9 @@ async function tryResumeFromReaction(deps, platformId, postId, username) {
67645
67687
  const sessionId = `${platformId}:${persistedSession.threadId}`;
67646
67688
  if (deps.registry.hasById(sessionId))
67647
67689
  return false;
67648
- const allowedUsers = new Set(persistedSession.sessionAllowedUsers);
67649
67690
  const platform = deps.platforms.get(platformId);
67650
- if (!allowedUsers.has(username) && !platform?.isUserAllowed(username)) {
67691
+ const sessionAllowedUsers = new Set(persistedSession.sessionAllowedUsers || [persistedSession.startedBy].filter(Boolean));
67692
+ if (!platform || !isAuthorizedForSession({ username, platform, sessionAllowedUsers })) {
67651
67693
  if (platform) {
67652
67694
  await platform.createPost(`⚠️ @${username} is not authorized to resume this session`, persistedSession.threadId);
67653
67695
  }
@@ -68233,11 +68275,11 @@ class SessionManager extends EventEmitter4 {
68233
68275
  }
68234
68276
  return;
68235
68277
  }
68236
- async sendFollowUp(threadId, message, files, username, displayName) {
68278
+ async sendFollowUp(threadId, message, files, username, displayName, options) {
68237
68279
  const session = this.findSessionByThreadId(threadId);
68238
68280
  if (!session || !session.claude.isRunning())
68239
68281
  return;
68240
- await sendFollowUp(session, message, files, this.getContext(), username, displayName);
68282
+ await sendFollowUp(session, message, files, this.getContext(), username, displayName, options);
68241
68283
  }
68242
68284
  isSessionActive() {
68243
68285
  return this.registry.size > 0;
@@ -68251,8 +68293,8 @@ class SessionManager extends EventEmitter4 {
68251
68293
  return false;
68252
68294
  return this.registry.getPersistedByThreadId(threadId) !== undefined;
68253
68295
  }
68254
- async resumePausedSession(threadId, message, files) {
68255
- await resumePausedSession(threadId, message, files, this.getContext());
68296
+ async resumePausedSession(threadId, message, files, username) {
68297
+ await resumePausedSession(threadId, message, files, this.getContext(), username);
68256
68298
  }
68257
68299
  getPersistedSession(threadId) {
68258
68300
  return this.registry.getPersistedByThreadId(threadId);
@@ -78305,7 +78347,7 @@ async function handleMessage(client, session, post2, user, options) {
78305
78347
  }
78306
78348
  const files2 = post2.metadata?.files;
78307
78349
  if (content || files2?.length) {
78308
- await session.resumePausedSession(threadRoot, content, files2);
78350
+ await session.resumePausedSession(threadRoot, content, files2, username);
78309
78351
  }
78310
78352
  return;
78311
78353
  }
@@ -51043,7 +51043,6 @@ function formatBytes(bytes) {
51043
51043
 
51044
51044
  // src/operations/streaming/handler.ts
51045
51045
  var log2 = createLogger("streaming");
51046
- var MAX_UPLOAD_SIZE = 100 * 1024 * 1024;
51047
51046
  async function postSkippedFilesFeedback(platform, threadId, skipped) {
51048
51047
  if (skipped.length === 0)
51049
51048
  return;
@@ -55810,7 +55809,7 @@ function createPassthroughHandler(slashCommand) {
55810
55809
  return { handled: false };
55811
55810
  }
55812
55811
  if (ctx.isAllowed) {
55813
- await ctx.sessionManager.sendFollowUp(ctx.threadId, `/${slashCommand}`);
55812
+ await ctx.sessionManager.sendFollowUp(ctx.threadId, `/${slashCommand}`, undefined, undefined, undefined, { system: true });
55814
55813
  }
55815
55814
  return { handled: true };
55816
55815
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-threads",
3
- "version": "1.16.0",
3
+ "version": "1.16.1",
4
4
  "description": "Share Claude Code sessions live in a Mattermost channel with interactive features",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",