@cohortapp/agent-sdk 2.3.1 → 2.3.2

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.
@@ -16,6 +16,7 @@ import { join } from "node:path";
16
16
  import { parseInboxItemYaml, scanInbox } from "./inbox-scan-poller.mjs";
17
17
  import { markDispatched, markInboxProcessed, writeInboxItem } from "./utils.mjs";
18
18
  import { eventToInboxItem } from "../../lib/channels/inbox-item.mjs";
19
+ import { outcomeSourceShareable } from "../daemon/session-outcomes.mjs";
19
20
 
20
21
  async function makeRoot() {
21
22
  const p = join(tmpdir(), `inbox-scan-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
@@ -229,3 +230,66 @@ raw_ref: "${item.raw_ref || ""}"
229
230
  if (item.kind) yaml += `kind: "${item.kind}"\n`;
230
231
  return yaml;
231
232
  }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // CONFIDENTIALITY — the privacy signal must survive the YAML round-trip
236
+ // ---------------------------------------------------------------------------
237
+ //
238
+ // The daemon's outcome gate (scripts/daemon/session-outcomes.mjs) is fail-closed
239
+ // and admits a turn's commitments as ORG-VISIBLE only on a POSITIVE public
240
+ // signal, because Slack's conversation-ID prefix is not a reliable type
241
+ // indicator — private channels carry `C…` ids too. That signal is minted at
242
+ // ingest and reaches the daemon only through this writer/reader pair, so the
243
+ // round-trip is part of the security boundary and is tested as such.
244
+
245
+ test("privacy signals survive writeInboxItem → scanInbox, and drive the outcome gate", async () => {
246
+ const root = await makeRoot();
247
+ try {
248
+ const base = {
249
+ service: "slack",
250
+ sender: "casey",
251
+ sender_privilege: "unknown",
252
+ timestamp: "2026-06-09T10:00:00.000Z",
253
+ content: "hello",
254
+ thread_id: "",
255
+ is_reply: false,
256
+ priority_signals: { from_ceo: false, tagged_urgent: false, contains_deadline: false, mentions_agent: true },
257
+ };
258
+ writeInboxItem("slack", {
259
+ ...base, id: "pub-1", channel: "general", channel_id: "C0GENERAL",
260
+ channel_type: "channel", is_private: false, is_dm: false,
261
+ subject: "#general", raw_ref: "slack:C0GENERAL:1",
262
+ }, root);
263
+ // A PRIVATE channel that Slack minted with a `C…` id — the case the old
264
+ // prefix test admitted, and the leak this whole chain exists to stop.
265
+ writeInboxItem("slack", {
266
+ ...base, id: "priv-1", channel: "leadership", channel_id: "C0LEADERSHIP",
267
+ channel_type: "group", is_private: true, is_dm: false,
268
+ subject: "#leadership", raw_ref: "slack:C0LEADERSHIP:2",
269
+ }, root);
270
+ // An item minted before/without any privacy resolution.
271
+ writeInboxItem("slack", {
272
+ ...base, id: "unk-1", channel: "mystery", channel_id: "C0MYSTERY",
273
+ subject: "#mystery", raw_ref: "slack:C0MYSTERY:3",
274
+ }, root);
275
+
276
+ const byId = Object.fromEntries(
277
+ scanInbox("slack", { agentRoot: root }).items.map((i) => [i.id, i])
278
+ );
279
+ assert.equal(Object.keys(byId).length, 3);
280
+
281
+ assert.equal(byId["pub-1"].channel_type, "channel");
282
+ assert.equal(byId["pub-1"].is_private, false, "an explicit false must survive as false, not vanish");
283
+ assert.equal(byId["priv-1"].channel_type, "group");
284
+ assert.equal(byId["priv-1"].is_private, true);
285
+ assert.equal(
286
+ "is_private" in byId["unk-1"], false,
287
+ "an unresolved item must carry NO privacy field — a defaulted `false` would forge a public verdict"
288
+ );
289
+ assert.equal("channel_type" in byId["unk-1"], false);
290
+
291
+ assert.deepEqual(outcomeSourceShareable(byId["pub-1"]), { shareable: true, reason: "public-channel" });
292
+ assert.deepEqual(outcomeSourceShareable(byId["priv-1"]), { shareable: false, reason: "private-channel" });
293
+ assert.deepEqual(outcomeSourceShareable(byId["unk-1"]), { shareable: false, reason: "privacy-unknown" });
294
+ } finally { await rmRoot(root); }
295
+ });
@@ -306,6 +306,11 @@ function handleWebhook(webhook, identity, peerSlackIds) {
306
306
  const payload = {
307
307
  ...webhook,
308
308
  channel_id: event.channel || "",
309
+ // CONFIDENTIALITY: carry Slack's conversation type through to the reader
310
+ // (slack-poller's events-JSON branch). The id prefix is not a reliable type
311
+ // indicator, so this is the only signal that lets the daemon's outcome gate
312
+ // establish a source as public. Omitted when Slack didn't send one.
313
+ ...(event.channel_type ? { channel_type: event.channel_type } : {}),
309
314
  received_at: new Date().toISOString(),
310
315
  source: "cloud-relay",
311
316
  };
@@ -12,6 +12,9 @@ import {
12
12
  AGENT_REPO_DIR,
13
13
  extractSlackAttachments,
14
14
  downloadSlackAttachment,
15
+ // One shared decoder for Slack's `channel_type` → the confidentiality fields
16
+ // (channel_type / is_private / is_dm) every ingest path stamps on an item.
17
+ slackChannelPrivacy,
15
18
  } from "./utils.mjs";
16
19
 
17
20
  import {
@@ -82,6 +85,12 @@ async function getMonitoredChannels() {
82
85
  id: c.id,
83
86
  name: c.name || c.id,
84
87
  priority: CHANNEL_PRIORITY_OVERRIDES[c.name] || "normal",
88
+ // CONFIDENTIALITY. `types` includes private_channel, and a private
89
+ // channel can carry a `C…` id — the prefix proves nothing. Keep Slack's
90
+ // own verdict so items minted from this channel can carry it downstream
91
+ // to the daemon's fail-closed outcome gate. Only a real boolean counts;
92
+ // anything else stays undefined (unknown ⇒ not established as public).
93
+ is_private: typeof c.is_private === "boolean" ? c.is_private : undefined,
85
94
  }));
86
95
  channelsCachedAt = Date.now();
87
96
  return cachedChannels;
@@ -318,6 +327,9 @@ export async function pollSlack() {
318
327
  },
319
328
  raw_ref: rawRef,
320
329
  channel_id: im.id,
330
+ channel_type: "im",
331
+ is_private: true,
332
+ is_dm: true,
321
333
  });
322
334
  }
323
335
  }
@@ -411,6 +423,12 @@ export async function pollSlack() {
411
423
  thread_id: msg.thread_ts || "",
412
424
  thread_context: channelThreadContext,
413
425
  is_reply: !!msg.thread_ts,
426
+ // Slack's own privacy verdict (see getMonitoredChannels); undefined
427
+ // when unknown so the downstream gate stays fail-closed.
428
+ is_private: channel.is_private,
429
+ channel_type: typeof channel.is_private === "boolean"
430
+ ? (channel.is_private ? "group" : "channel")
431
+ : undefined,
414
432
  attachments: attachments.length > 0 ? attachments : undefined,
415
433
  priority_signals: {
416
434
  from_ceo: isCeo,
@@ -538,6 +556,9 @@ export async function pollSlack() {
538
556
  mentions_agent: true,
539
557
  },
540
558
  raw_ref: `slack:${im.id}:${msg.ts}`,
559
+ channel_type: "im",
560
+ is_private: true,
561
+ is_dm: true,
541
562
  });
542
563
  }
543
564
 
@@ -722,6 +743,13 @@ export async function pollSlack() {
722
743
  mentions_agent: isDmThread || (loadAgent().firstName && new RegExp(loadAgent().firstName, "i").test(replyText)),
723
744
  },
724
745
  raw_ref: `slack:${channelId}:${reply.ts}`,
746
+ // Same privacy verdict as the parent conversation: an im is private
747
+ // by construction, a channel carries whatever Slack said it is.
748
+ ...(isDmThread
749
+ ? { channel_type: "im", is_private: true, is_dm: true }
750
+ : (chanInfo && typeof chanInfo.is_private === "boolean"
751
+ ? { channel_type: chanInfo.is_private ? "group" : "channel", is_private: chanInfo.is_private }
752
+ : {})),
725
753
  });
726
754
  }
727
755
  } catch (err) {
@@ -824,6 +852,10 @@ export async function pollSlack() {
824
852
  mentions_agent: loadAgent().firstName ? new RegExp(loadAgent().firstName, "i").test(content) : false,
825
853
  },
826
854
  raw_ref: eventRef,
855
+ // Slack's conversation type off the delivered envelope — the only
856
+ // dependable privacy signal (the id prefix is not one). Absent on
857
+ // older payloads, in which case we assert nothing.
858
+ ...slackChannelPrivacy(data.channel_type || evt.channel_type),
827
859
  _inbox_file: file,
828
860
  });
829
861
 
@@ -37,7 +37,7 @@ import { existsSync, readFileSync, mkdirSync, appendFileSync } from "node:fs";
37
37
  import { dirname, join, resolve } from "node:path";
38
38
  import { fileURLToPath } from "node:url";
39
39
 
40
- import { writeInboxItem, resolvePrivilege, resolveName, AGENT_REPO_DIR } from "./utils.mjs";
40
+ import { writeInboxItem, resolvePrivilege, resolveName, slackChannelPrivacy, AGENT_REPO_DIR } from "./utils.mjs";
41
41
  import { handleSlashCommand, postSlashResponse } from "./lib/slash-command-handlers.mjs";
42
42
 
43
43
  /**
@@ -276,6 +276,20 @@ export function shouldKeepEvent(envelope, { ownSlackId, peerSlackIds, reactions,
276
276
  // Translation — Slack envelope → poller-compatible inbox item
277
277
  // ---------------------------------------------------------------------------
278
278
 
279
+ /**
280
+ * CONFIDENTIALITY SIGNAL. Slack's conversation-ID prefix is NOT a reliable type
281
+ * indicator — a private channel can carry a `C…` id, and only `D` for a DM is
282
+ * dependable. The dependable signal is the event envelope's own `channel_type`,
283
+ * which this listener used to drop on the floor. We now carry it onto the inbox
284
+ * item, together with the booleans it implies, because the daemon's outcome gate
285
+ * (scripts/daemon/session-outcomes.mjs `outcomeSourceShareable`) requires a
286
+ * POSITIVE public signal before a turn's commitments may become org-visible.
287
+ *
288
+ * Re-exported from ./utils.mjs, the one place the decoding is defined (the
289
+ * Web-API poller stamps the same fields).
290
+ */
291
+ export { slackChannelPrivacy } from "./utils.mjs";
292
+
279
293
  /**
280
294
  * Convert a Slack Socket Mode envelope to the same inbox-item shape that
281
295
  * `slack-poller.mjs` produces. Pure function; all I/O happens at the caller.
@@ -307,6 +321,9 @@ export function eventToInboxItem(envelope, { ownSlackId, principalSlackId, agent
307
321
  const channelLabel = isDm ? `dm/${senderName}` : channelId;
308
322
  const verb = event.type === "reaction_added" ? "added" : "removed";
309
323
  const targetText = typeof threadContext === "string" ? threadContext : "";
324
+ // A reaction envelope carries no `channel_type`; only the `D` prefix is
325
+ // dependable, so that is all we assert (and only in the private direction).
326
+ const reactionPrivacy = isDm ? { channel_type: "im", is_private: true, is_dm: true } : {};
310
327
  return {
311
328
  id: `${targetTs.replace(".", "-")}-reaction-${reactor}`,
312
329
  service: "slack",
@@ -329,6 +346,7 @@ export function eventToInboxItem(envelope, { ownSlackId, principalSlackId, agent
329
346
  raw_ref: `slack:${channelId}:${targetTs}:reaction`,
330
347
  source: "socket-mode",
331
348
  kind: "reaction",
349
+ ...reactionPrivacy,
332
350
  };
333
351
  }
334
352
 
@@ -338,6 +356,12 @@ export function eventToInboxItem(envelope, { ownSlackId, principalSlackId, agent
338
356
  const channelId = event.channel || "";
339
357
  const threadTs = event.thread_ts || "";
340
358
  const isReply = !!threadTs && threadTs !== ts;
359
+ // Slack's own conversation type, when the envelope carries one. See
360
+ // `slackChannelPrivacy` — this, not the id prefix, is the privacy signal.
361
+ const privacy = slackChannelPrivacy(event.channel_type);
362
+ // Routing/labelling stays on the historical `D`-prefix test (an `im` always
363
+ // has a `D` id anyway, and promoting an `mpim` to "DM" would change who the
364
+ // agent auto-replies to). `privacy` only informs the confidentiality fields.
341
365
  const isDm = typeof channelId === "string" && channelId.startsWith("D");
342
366
 
343
367
  // Friendly labels. We can't resolve a channel name without an API call
@@ -383,6 +407,8 @@ export function eventToInboxItem(envelope, { ownSlackId, principalSlackId, agent
383
407
  },
384
408
  raw_ref: `slack:${channelId}:${ts}`,
385
409
  source: "socket-mode",
410
+ // channel_type / is_private / is_dm — only when Slack actually told us.
411
+ ...privacy,
386
412
  };
387
413
  if (isCc) item.kind = "channel_cc";
388
414
  return item;
@@ -279,6 +279,58 @@ test("eventToInboxItem: thread reply preserves thread_id + is_reply", () => {
279
279
  assert.equal(item.thread_id, "1735689500.000100");
280
280
  });
281
281
 
282
+ test("eventToInboxItem: carries Slack's channel_type as the privacy signal", () => {
283
+ // CONFIDENTIALITY. The listener used to drop `event.channel_type` entirely and
284
+ // derive privacy from the id prefix alone — but Slack mints `C…` ids for
285
+ // PRIVATE channels too, so downstream (the daemon's fail-closed outcome gate)
286
+ // had nothing dependable to admit on. Every kept event now carries the type,
287
+ // and the booleans it implies, verbatim off the envelope.
288
+ const mk = (channel, channelType) => eventToInboxItem(
289
+ makeEnvelope({
290
+ event: {
291
+ user: HUMAN_SLACK_ID,
292
+ channel,
293
+ ...(channelType === undefined ? {} : { channel_type: channelType }),
294
+ text: `<@${AGENT_SLACK_ID}> noted`,
295
+ ts: "1735689700.002000",
296
+ },
297
+ }),
298
+ { ownSlackId: AGENT_SLACK_ID, principalSlackId: PRINCIPAL_SLACK_ID, agentFirstName: "Ravi" }
299
+ );
300
+
301
+ const pub = mk("C0CHANNEL1", "channel");
302
+ assert.equal(pub.channel_type, "channel");
303
+ assert.equal(pub.is_private, false);
304
+ assert.equal(pub.is_dm, false);
305
+
306
+ // A private channel WITH a public-looking `C…` id — the leak vector.
307
+ const priv = mk("C0PRIVATE1", "group");
308
+ assert.equal(priv.channel_type, "group");
309
+ assert.equal(priv.is_private, true);
310
+
311
+ const mpim = mk("C0MPIM0001", "mpim");
312
+ assert.equal(mpim.channel_type, "mpim");
313
+ assert.equal(mpim.is_private, true);
314
+ assert.equal(mpim.is_dm, true);
315
+ assert.equal(mpim.channel, "C0MPIM0001", "an mpim is still routed as a channel, not promoted to a DM");
316
+
317
+ const im = mk("DABCDEF01", "im");
318
+ assert.equal(im.channel_type, "im");
319
+ assert.equal(im.is_private, true);
320
+ assert.equal(im.is_dm, true);
321
+
322
+ // No type on the envelope ⇒ assert NOTHING (a defaulted `is_private:false`
323
+ // would forge a public verdict the gate then admits on).
324
+ const unknown = mk("C0CHANNEL1", undefined);
325
+ assert.equal("channel_type" in unknown, false);
326
+ assert.equal("is_private" in unknown, false);
327
+ assert.equal("is_dm" in unknown, false);
328
+
329
+ // An unrecognised type is likewise not decoded into a verdict.
330
+ const weird = mk("C0CHANNEL1", "external_shared");
331
+ assert.equal("is_private" in weird, false);
332
+ });
333
+
282
334
  test("eventToInboxItem: first-name mention (no @) still flips mentions_agent", () => {
283
335
  // "Hey Ravi, can you …" in a channel — no explicit @-mention but the
284
336
  // agent's first name appears. The poller uses this same heuristic.
@@ -10,6 +10,35 @@ export const AGENT_REPO_DIR = process.env.AGENT_DIR || join(__dirname, "../..");
10
10
  export const SOPHIE_AI_DIR = AGENT_REPO_DIR;
11
11
  export const ATTACHMENTS_DIR = join(AGENT_REPO_DIR, "state", "inbox", "attachments");
12
12
 
13
+ /**
14
+ * CONFIDENTIALITY SIGNAL — decode Slack's conversation type into the fields an
15
+ * inbox item carries. Every Slack ingest path (Socket Mode, the Web-API poller,
16
+ * the cloud relay) routes through this one decoder so the meaning of
17
+ * `channel_type` / `is_private` / `is_dm` is defined in exactly one place.
18
+ *
19
+ * WHY IT EXISTS: Slack's conversation-ID prefix is NOT a reliable type
20
+ * indicator — a private channel can and does carry a `C…` id; only `D` for a DM
21
+ * is dependable. The dependable signal is `channel_type` on the event envelope
22
+ * ("channel" public | "group" private | "im" DM | "mpim" group DM), so that is
23
+ * what we decode and carry to the daemon's fail-closed outcome gate
24
+ * (scripts/daemon/session-outcomes.mjs `outcomeSourceShareable`).
25
+ *
26
+ * Returns `{}` for anything unrecognised or missing: asserting nothing keeps
27
+ * that gate closed, which is the safe direction.
28
+ *
29
+ * @param {unknown} channelType raw Slack `channel_type`
30
+ * @returns {{channel_type?:string, is_private?:boolean, is_dm?:boolean}}
31
+ */
32
+ export function slackChannelPrivacy(channelType) {
33
+ switch (String(channelType || "").trim().toLowerCase()) {
34
+ case "channel": return { channel_type: "channel", is_private: false, is_dm: false };
35
+ case "group": return { channel_type: "group", is_private: true, is_dm: false };
36
+ case "im": return { channel_type: "im", is_private: true, is_dm: true };
37
+ case "mpim": return { channel_type: "mpim", is_private: true, is_dm: true };
38
+ default: return {};
39
+ }
40
+ }
41
+
13
42
  export function writeInboxItem(service, item, agentRoot = AGENT_REPO_DIR) {
14
43
  const dir = join(agentRoot, "state", "inbox", service);
15
44
  mkdirSync(dir, { recursive: true });
@@ -159,6 +188,24 @@ raw_ref: "${item.raw_ref || ""}"
159
188
  yaml += `channel_id: "${item.channel_id}"\n`;
160
189
  }
161
190
 
191
+ // CONFIDENTIALITY SIGNALS. Slack's conversation-ID prefix is not a reliable
192
+ // type indicator (a private channel can carry a `C…` id), so downstream
193
+ // consumers — notably the daemon's fail-closed outcome gate,
194
+ // scripts/daemon/session-outcomes.mjs `outcomeSourceShareable` — need the
195
+ // conversation type/privacy the ingest layer read off the event itself.
196
+ // Written ONLY when actually known: an absent line must stay absent, because
197
+ // `is_private: false` is a positive "this is public" assertion and the reader
198
+ // (inbox-scan-poller.mjs `parseInboxItemYaml`) decodes it as a tri-state.
199
+ if (item.channel_type) {
200
+ yaml += `channel_type: "${item.channel_type}"\n`;
201
+ }
202
+ if (typeof item.is_private === "boolean") {
203
+ yaml += `is_private: ${item.is_private}\n`;
204
+ }
205
+ if (typeof item.is_dm === "boolean") {
206
+ yaml += `is_dm: ${item.is_dm}\n`;
207
+ }
208
+
162
209
  // Add event_type if present (e.g., message_changed, reaction)
163
210
  if (item.event_type) {
164
211
  yaml += `event_type: "${item.event_type}"\n`;