fix: tolerate revoked Slack channel visibility during discovery

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-22 13:17:06 -05:00
1 parent 4e00210da0
commit d23bb857a7
5 files changed
+50 -5

No files matched your search

+1 -1
View File
@@ -145,7 +145,7 @@ export const SLACK_TOOLS = [
"assistant.search.context",
["search:read.public", "search:read.files"],
"read",
"Search authorized channels by text, author and time. Reports native search or bounded history scan and its coverage. Never claim a partial scan is exhaustive.",
"Search authorized channel history by text, author and time. Pass channels as an array of channel IDs, query as plain text, and limit from 1 to 20 (matches per channel, not messages scanned). Reports bounded scan coverage; never claim it is exhaustive.",
{
channels: z.array(channel).min(1).max(10),
query: text,
@@ -4002,6 +4002,19 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
const read = await executeSlackTool(db, binding, "slack_history", { channel: "COTHER" }, fetched as typeof fetch);
expect(read).toMatchObject({ sourceTrust: "untrusted", nextCursor: "older", hasMore: true });
expect(JSON.stringify(read)).toContain("UUNLINKED");
const directoryFetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const method = String(input).split("/").at(-1);
const args = Object.fromEntries(new URLSearchParams(String(init?.body ?? "")));
if (method === "conversations.list") return Response.json({ ok: true, channels: [{ id: "CGONE", is_member: true }, { id: "COTHER", is_member: true }], response_metadata: { next_cursor: "directory-next" } });
if (method === "conversations.info" && args.channel === "CGONE") return Response.json({ ok: false, error: "channel_not_found" });
return fetched(input, init);
});
await expect(executeSlackTool(db, binding, "slack_channels", {}, directoryFetch as typeof fetch)).resolves.toMatchObject({ channels: [{ id: "COTHER" }], nextCursor: "directory-next" });
await expect(executeSlackTool(db, binding, "slack_search", { channels: ["COTHER"], query: "ship Tuesday", limit: 10 }, fetched as typeof fetch)).resolves.toMatchObject({
mode: "bounded_history", exhaustive: false,
matches: [{ user: "UUNLINKED", ts: "7000.1" }],
inspected: [{ channel: "COTHER", count: 1, nextCursor: "older", hasMore: true }],
});
await expect(executeSlackTool(db, binding, "slack_history", { channel: "GPRIVATE" }, fetched as typeof fetch)).rejects.toThrow("in a DM");
vi.stubEnv("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET", "slack-tools-integration-signing-secret");
onTestFinished(() => vi.unstubAllEnvs());
@@ -9,6 +9,7 @@ import { slackClient } from "./slack-client.js";
import type { SlackTaskAuthority } from "./slack-authority.js";
import type { Db } from "@paperclipai/db";
import { SLACK_TOOLS } from "@paperclipai/shared";
import { tooManyRequests, unprocessable } from "../../errors.js";
const authority = {
endpoint: {
id: "endpoint",
@@ -48,6 +49,18 @@ const fakeDb = (rows: unknown[]) =>
select: () => ({ from: () => ({ where: async () => rows }) }),
}) as unknown as Db;
describe("Slack access and publication boundaries", () => {
it("treats missing channels as access denials without hiding provider failures", async () => {
for (const code of ["slack_channel_not_found", "slack_not_in_channel"]) {
const api = apiFor({});
api.mockRejectedValue(unprocessable("Slack rejected the lookup", { code }));
await expect(authorizeSlackChannel(authority, api, "C1")).rejects.toMatchObject({ status: 403 });
}
for (const error of [tooManyRequests("Rate limited"), unprocessable("Missing scope", { code: "slack_missing_scope" })]) {
const api = apiFor({});
api.mockRejectedValue(error);
await expect(authorizeSlackChannel(authority, api, "C1")).rejects.toBe(error);
}
});
it("reads a public shared channel without checking the enabled response destination", async () => {
expect((await authorizeSlackChannel(authority, apiFor({}), "C1")).id).toBe(
"C1",
+16 -4
View File
@@ -1,6 +1,6 @@
import { and, eq, sql } from "drizzle-orm";
import { chatActions, chatEndpointResources, type Db } from "@paperclipai/db";
import { forbidden, unprocessable } from "../../errors.js";
import { HttpError, forbidden, unprocessable } from "../../errors.js";
import type { SlackTaskAuthority } from "./slack-authority.js";
import {
nextCursor,
@@ -15,9 +15,21 @@ export async function authorizeSlackChannel(
api: Api,
channelId: string,
) {
const channel = object(
(await api("conversations.info", { channel: channelId })).channel,
);
let channel: SlackObject;
try {
channel = object(
(await api("conversations.info", { channel: channelId })).channel,
);
} catch (error) {
// Membership can disappear between listing and inspection. Slack also
// hides inaccessible conversations with channel_not_found. Treat only
// these definite access failures as denials; outages must still surface.
if (error instanceof HttpError && error.status === 422 &&
["slack_channel_not_found", "slack_not_in_channel"].includes(String(object(error.details).code))) {
throw forbidden("This Slack channel is no longer accessible to the bot");
}
throw error;
}
if (
channel.id !== channelId ||
(channel.is_member !== true && channel.is_im !== true)
+7
View File
@@ -38,6 +38,13 @@ history scan is not workspace-wide search and does not automatically inspect
thread replies. Fetch further history/thread pages when needed. Report omitted
history, rate limits, missing scopes and unavailable Slack features accurately.
For example, to search the assigned channel, call `slack_search` with
`{"channels":["C012AB3CD"],"query":"launch decision","limit":10}`, substituting
the supplied channel ID. `channels` is an array; `limit` is at most 20 matches,
not the history page size. Do not add Slack search syntax to a channel ID or
pass unsupported fields. A schema rejection means the arguments need correcting;
it does not mean another Slack connection is needed.
## Collaboration and delivery
Use Slack messages, uploads, reactions, pins, bookmarks, topics, canvases and lists