Skip to content

Diagnostic Slack

is_ext_shared: the channel is shared with another org

Every other note in this batch is about a message that did not arrive. This one is about a message that arrived perfectly, for eight months, in a room that has a vendor in it.

Nothing failed. There is no error string to search for, no exit code, no retry, no alert. chat.postMessage returned ok: true every single time, because from the app's side nothing about the channel changed: same ID, same membership, same call. Somebody shared it with another organisation, and the deploy failures with customer names in them have been going out of the building ever since.

Read-only token Python and Node.js Tests included
A wooden table topped with papers and a pen
Photo by 2H Media on Unsplash
The short answer

Slack Connect shares a channel with an external organisation while keeping the channel ID identical. Nothing in your configuration moves, nothing in your code notices, and messages continue to flow. conversations.info is where it becomes visible: is_ext_shared for a channel shared outside your org, is_pending_ext_shared for an invitation not yet accepted, is_org_shared for a channel spanning workspaces inside one Enterprise Grid org, and the vaguer is_shared underneath both.

Those flags change more than the audience. In an externally shared channel a member's team_id is not yours, users.info can answer user_not_found for a perfectly real person, profile.email is absent whatever scopes you hold, and mentions of internal users and links to internal channels do not resolve for half the room. The script below reads the flags, resolves the membership against your own team_id from auth.test, and prints the list of assumptions your send path is quietly still making.

The problem in plain words

The absence of an error is the whole problem. Every other failure in this section announces itself somewhere, even if only as ok: false in a body nobody reads. This one produces a clean log, a green dashboard and a successful delivery, and the only symptom is that people outside the company can read your stack traces. Detection cannot be an error handler here, because there is no error. It has to be a scheduled assertion about state.

It usually arrives from the side. A team starts working with a vendor and shares the channel that the conversation is already happening in, which is the sensible thing to do and takes four clicks. Nobody involved in that decision knows that the channel has an alerting integration in it, because the integration was added by a different team eighteen months earlier and is a quiet member that never speaks except when something breaks. The is_ext_shared flag flips and no notification reaches anybody who would recognise what it means.

Then the assumptions break, mostly silently. Code that resolves a member through users.info before deciding what to include gets user_not_found for external members and either crashes or, far more often, falls through to a default that includes everything. Code that looks up an email to match a Slack user against an internal directory gets nothing back and treats the person as unknown. An @here reaches the vendor. A link to an internal channel renders as a dead reference for half the room, which is the one harmless case on this list.

Vendor joinsthe roomfour clicks, nodeploySame channel IDnothing in configmovesok: true, everysendno error to findEight months ofalertsstack traces,namesNothing everraisedthe silence is thebug
Nothing here is an error, so nothing here can be caught by an error handler. Detection has to be a scheduled assertion about state, because the failure is that every single call worked.

Why it happens

The channel ID does not change, so nothing in your configuration can catch it. No environment variable moves, no name changes, no deploy happens. Everything a code review looks at is identical before and after. The only thing that changed is a boolean on the far side of an API call nobody makes.

is_shared alone does not mean external. On Enterprise Grid a channel shared between two of your own workspaces sets is_shared and is_org_shared and is entirely internal. Treating those as the same finding produces a report full of false alarms, and a report full of false alarms is a report nobody reads the real finding in. The script keeps external, pending-external and org-shared as three verdicts.

team_id is the assumption that quietly stops holding. Almost every app is written as though every member of every channel belongs to the workspace the token was installed in. In a Connect channel that is false, and it is false in a way that produces no error: conversations.members returns the IDs happily and users.info is the call that refuses.

user_not_found is a correct answer here, not a bug. A member of the other organisation is not in your workspace's user table and your token is not entitled to resolve them. Any code that looks a member up before deciding what to send needs a deliberate branch for that, and the safe branch is to send less rather than to fall through.

Pending is worth reporting as loudly as shared. is_pending_ext_shared means the invitation is out. When it is accepted nothing on your side is asked to change, so the moment to move an integration is while the answer is still no.

The fix, as a flow

No arrow in this chain fails, which is the only honest way to draw it. Every send returned ok, the ID never moved, and the symptom is the absence of a symptom. The fix branch separates the two kinds of sharing, because a Grid workspace has plenty of the internal one and flagging those is how the real finding gets scrolled past.

Flags, then every memberagainst your own team idis_ext_sharedoutside the companyInvitation pendingaccepted without asking youis_org_sharedanother workspace, internalMember will not resolvethat is the classificationNo flags setinternal, nothing listed
A member who will not resolve is the answer rather than a failed call. They belong to the other organisation, they are not in your user table, and code that resolves before deciding what to send needs a branch that sends less.

How to fix it

Establish your own team_id first

One auth.test. Every membership verdict below is a comparison against it, and hardcoding a workspace ID in a script that audits workspace boundaries is its own small joke.

Read the four sharing flags, not one of them

is_ext_shared, is_pending_ext_shared, is_org_shared and is_shared, in that order of precedence. They answer different questions and only the first is a data-egress finding. Report the other two rather than folding them in, because the difference between "outside the company" and "another one of our workspaces" is the difference between an incident and a note.

Enumerate the membership and resolve it

conversations.members is cursor-paginated and returns IDs only. Resolving each through users.info is what turns a list of IDs into an audience. Cap it, cache it, and expect a share of the calls to refuse: users.info is a Tier 4 method but a hundred members is still a hundred calls.

Treat an unresolvable member as a finding, not as an error

This is the step most audits get wrong. user_not_found for a member of a Connect channel is Slack telling you the person is outside your organisation. Counting those as failures loses the signal; counting them as external members is the signal.

Print the assumptions rather than only the verdict

The verdict is one line and it is not the useful part. The useful part is the list of things your send path still believes: that members resolve, that emails are readable, that a mention renders for everybody, that a file link is as private as the channel. The script prints that list for the state it found, and it prints nothing for an internal channel.

Put the assertion in the send path, not in a quarterly review

The durable repair is three lines in the code that sends: read conversations.info for the target, refuse when is_ext_shared is true unless that target is explicitly marked as external, and cache the answer for an hour. Where external posting is intended, redact for that target specifically rather than trusting the message body to be safe.

How to check it worked

After the sensitive integration has been moved, re-run over both channels. The internal one should come back with no assumptions listed at all, which is the only clean output this script produces.

python3 slack_external_channel_audit.py C0VENDOR11 C0INTERNAL
# identity   T0ACME1111 as U0APPBOT11
# C0VENDOR11  sharing  external        2 connected team(s) beyond your own
# C0VENDOR11  members  14 resolved, 5 external-org, 2 unresolvable
# C0INTERNAL  sharing  internal        no sharing flags are set
# C0INTERNAL  members  34 resolved, 0 external-org, 0 unresolvable
# 2 channel(s) checked, 1 reaching outside the organisation

The full code

Three pure functions and three GET methods. sharing_state keeps external, pending and org-shared apart, because collapsing them is what makes this report unreadable on Enterprise Grid. member_origin places one resolved member against your own team_id and treats an unresolvable member as evidence rather than as a failed call. assumptions_broken is the function worth stealing even if you never run the rest: it returns what your send path still believes, and it returns an empty list for an internal channel.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 108 Slack fixes, free and open source.
slack_external_channel_audit.py
"""Find the Slack channels an integration posts to that reach outside your org.

Read only, and the only note in this section where nothing is failing. Slack
Connect keeps the channel ID identical when a channel is shared externally, so
conversations.info and the membership are the only places the change is visible.
Nothing is sent; the assertion to add to your send path is printed.
"""
import argparse
import logging
import os
import sys

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_external_channel_audit")

API = "https://slack.com/api/"

# What your send path still believes, per sharing state. The verdict is one line
# and this is the part somebody acts on.
ASSUMPTIONS = {
    "external": [
        ("every member resolves through users.info",
         "members of the other organisation are not in your user table and answer "
         "user_not_found. Code that resolves before deciding what to send needs a "
         "branch, and the safe branch sends less"),
        ("users:read.email gives me an address",
         "profile.email is absent for external members whatever scope you hold, so "
         "directory matching silently classifies them as unknown"),
        ("a mention reaches the people I mean",
         "internal user IDs and internal channel links do not resolve for the other "
         "organisation, and an @here reaches all of them"),
        ("the channel ID tells me who the audience is",
         "the ID did not change when the channel was shared. Nothing in your "
         "configuration moved and nothing in a code review would show it"),
        ("a file link is as private as the channel",
         "file, canvas and list sharing across Connect is separately governed. "
         "Blocked, it answers slack_connect_file_link_sharing_blocked. Not blocked, "
         "the file is outside the company"),
    ],
    "pending-external": [
        ("this is not shared yet, so there is time",
         "there is, and that is the point. When the invitation is accepted nothing "
         "on your side is asked to change: same ID, same members call, wider room"),
        ("somebody will tell us when it is accepted",
         "no notification reaches an app owner. The flag flips and the next send "
         "succeeds exactly as before"),
    ],
    "org-shared": [
        ("every member shares my team_id",
         "an org-shared channel spans workspaces inside one Enterprise Grid org, so "
         "members carry other team IDs while being entirely internal"),
        ("auth.test team_id describes the audience",
         "it describes the workspace the token was installed in, which is a subset "
         "of who is in this channel"),
    ],
    "shared-unclassified": [
        ("is_shared on its own tells me something actionable",
         "it does not. Read this channel with a token that can see the org before "
         "deciding whether it is external, and treat it as external until then"),
    ],
    "internal": [],
}

REPAIR_ASSERT = ("assert it in the send path: read conversations.info for the "
                 "target, refuse when is_ext_shared is true unless that target is "
                 "explicitly marked external, cache the answer for an hour")
REPAIR_MOVE = ("move the sensitive integration to an internal-only channel. Where "
               "external posting is intended, redact for that target specifically "
               "rather than trusting the message body")


def sharing_state(channel):
    """Who this channel reaches, from conversations.info. Pure.

    Four flags, in precedence order, kept as separate verdicts. Collapsing
    org-shared into external produces a report full of false alarms on Enterprise
    Grid, and a report full of false alarms is a report the real finding gets
    ignored in.
    """
    fields = channel or {}
    connected = [t for t in (fields.get("connected_team_ids") or []) if t]
    shared = [t for t in (fields.get("shared_team_ids") or []) if t]

    if fields.get("is_ext_shared") is True:
        return ("external",
                "is_ext_shared is set: this channel is shared with at least one "
                "organisation that is not yours (%d connected team id(s), %d shared). "
                "Every message sent here leaves the company and nothing errors."
                % (len(connected), len(shared)))
    if fields.get("is_pending_ext_shared") is True:
        return ("pending-external",
                "is_pending_ext_shared is set: an invitation to another organisation "
                "is outstanding. When it is accepted the channel ID stays the same "
                "and your integration is not asked anything.")
    if fields.get("is_org_shared") is True:
        return ("org-shared",
                "is_org_shared is set: shared across workspaces inside one Enterprise "
                "Grid organisation. Internal, and it still breaks the assumption that "
                "every member carries your team_id.")
    if fields.get("is_shared") is True:
        return ("shared-unclassified",
                "is_shared is set and neither is_ext_shared nor is_org_shared is. "
                "This token cannot tell which kind of sharing it is; treat it as "
                "external until a token that can see the org says otherwise.")
    return ("internal", "no sharing flags are set on this channel")


def member_origin(user, home_team_id):
    """Place one resolved member against your own workspace. Pure.

    `user` is the users.info profile, or None when the call refused. None is the
    interesting case and it is not an error: a member of the other organisation is
    not in your workspace's user table and your token is not entitled to resolve
    them, so Slack answers user_not_found about a real person.
    """
    if not user:
        return ("unresolvable",
                "users.info returned no profile. In a Connect channel that is the "
                "answer rather than a failure: this person belongs to another "
                "organisation and your token may not read them.")
    if user.get("is_stranger") is True:
        return ("external-org",
                "is_stranger is set, which is Slack saying plainly that this member "
                "is outside your organisation.")

    team = str(user.get("team_id") or "")
    home = str(home_team_id or "")
    if team and home and team != home:
        return ("other-team",
                "team_id %s is not %s. In an externally shared channel that is "
                "another organisation; in an org-shared channel it is another "
                "workspace in your own. The channel flags decide which, and this "
                "record does not." % (team, home))
    if user.get("is_ultra_restricted") is True or user.get("is_restricted") is True:
        return ("guest",
                "a guest account in your own workspace. Inside the company, and "
                "usually without access to the channels your message links to.")
    if user.get("is_bot") is True:
        return ("app", "another app in the room, which reads everything you send")
    return ("home", "a member of your own workspace")


def assumptions_broken(state):
    """What the send path still believes, for this sharing state. Pure.

    Empty for an internal channel, which is the only clean output this script
    produces.
    """
    return list(ASSUMPTIONS.get(state, ASSUMPTIONS["shared-unclassified"]))


def get(session, method, params):
    """One read call. GET only."""
    return session.get(API + method, params=params, timeout=30).json()


def all_members(session, cid, cap):
    """Member IDs, cursor paginated, stopped at a cap. GET only."""
    out, cursor = [], ""
    while len(out) < cap:
        params = {"channel": cid, "limit": 200}
        if cursor:
            params["cursor"] = cursor
        body = get(session, "conversations.members", params)
        if body.get("ok") is not True:
            log.warning("conversations.members on %s answered ok: false, error=%s",
                        cid, body.get("error"))
            return out
        out.extend(body.get("members") or [])
        cursor = (body.get("response_metadata") or {}).get("next_cursor") or ""
        if not cursor:
            break
    return out[:cap]


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("channels", nargs="+", help="channel IDs the integration posts to")
    ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
                    help="environment variable holding the bot token")
    ap.add_argument("--max-members", type=int, default=200,
                    help="stop resolving members past this many; users.info is one "
                         "call each")
    args = ap.parse_args()

    token = os.environ.get(args.token_env)
    if not token:
        log.error("set %s (channels:read, groups:read and users:read are enough)",
                  args.token_env)
        return 2

    s = requests.Session()
    s.headers.update({"Authorization": "Bearer " + token})

    auth = get(s, "auth.test", {})
    if auth.get("ok") is not True:
        raise SystemExit("auth.test answered 200 with ok: false, error=%s"
                         % auth.get("error"))
    home = auth.get("team_id")
    log.info("identity   %s as %s", home, auth.get("user_id"))

    outside = 0
    for cid in args.channels:
        info = get(s, "conversations.info", {"channel": cid})
        if info.get("ok") is not True:
            log.warning("%-12s sharing  unreadable, error=%s", cid, info.get("error"))
            continue

        state, detail = sharing_state(info.get("channel") or {})
        counts = {}
        for uid in all_members(s, cid, args.max_members):
            profile = get(s, "users.info", {"user": uid}).get("user")
            origin, _ = member_origin(profile, home)
            counts[origin] = counts.get(origin, 0) + 1

        external_members = counts.get("external-org", 0) + counts.get("unresolvable", 0)
        summary = ", ".join("%d %s" % (n, k) for k, n in sorted(counts.items()))

        if state == "internal" and not external_members:
            log.info("%-12s sharing  %-20s %s", cid, state, detail)
            log.info("%-12s members  %s", cid, summary or "none resolved")
            continue

        outside += 1
        log.warning("%-12s sharing  %-20s %s", cid, state, detail)
        log.warning("%-12s members  %s", cid, summary or "none resolved")
        for assumption, reality in assumptions_broken(state):
            log.warning("  assumes: %s", assumption)
            log.warning("  actually: %s", reality)
        log.warning("  repair: %s", REPAIR_ASSERT)
        log.warning("  repair: %s", REPAIR_MOVE)

    log.info("%d channel(s) checked, %d reaching outside the organisation",
             len(args.channels), outside)
    return 1 if outside else 0


if __name__ == "__main__":
    sys.exit(main())
slack-external-channel-audit.mjs
/**
 * Find the Slack channels an integration posts to that reach outside your org.
 *
 * Read only, and the only note in this section where nothing is failing. Slack
 * Connect keeps the channel ID identical when a channel is shared externally,
 * so conversations.info and the membership are the only places the change is
 * visible. Nothing is sent; the assertion to add to your send path is printed.
 */

const API = 'https://slack.com/api/';

// What your send path still believes, per sharing state. The verdict is one line
// and this is the part somebody acts on.
const ASSUMPTIONS = new Map([
  ['external', [
    ['every member resolves through users.info',
      'members of the other organisation are not in your user table and answer ' +
      'user_not_found. Code that resolves before deciding what to send needs a ' +
      'branch, and the safe branch sends less'],
    ['users:read.email gives me an address',
      'profile.email is absent for external members whatever scope you hold, so ' +
      'directory matching silently classifies them as unknown'],
    ['a mention reaches the people I mean',
      'internal user IDs and internal channel links do not resolve for the other ' +
      'organisation, and an @here reaches all of them'],
    ['the channel ID tells me who the audience is',
      'the ID did not change when the channel was shared. Nothing in your ' +
      'configuration moved and nothing in a code review would show it'],
    ['a file link is as private as the channel',
      'file, canvas and list sharing across Connect is separately governed. ' +
      'Blocked, it answers slack_connect_file_link_sharing_blocked. Not blocked, ' +
      'the file is outside the company'],
  ]],
  ['pending-external', [
    ['this is not shared yet, so there is time',
      'there is, and that is the point. When the invitation is accepted nothing on ' +
      'your side is asked to change: same ID, same members call, wider room'],
    ['somebody will tell us when it is accepted',
      'no notification reaches an app owner. The flag flips and the next send ' +
      'succeeds exactly as before'],
  ]],
  ['org-shared', [
    ['every member shares my team_id',
      'an org-shared channel spans workspaces inside one Enterprise Grid org, so ' +
      'members carry other team IDs while being entirely internal'],
    ['auth.test team_id describes the audience',
      'it describes the workspace the token was installed in, which is a subset of ' +
      'who is in this channel'],
  ]],
  ['shared-unclassified', [
    ['is_shared on its own tells me something actionable',
      'it does not. Read this channel with a token that can see the org before ' +
      'deciding whether it is external, and treat it as external until then'],
  ]],
  ['internal', []],
]);

const REPAIR_ASSERT = 'assert it in the send path: read conversations.info for ' +
  'the target, refuse when is_ext_shared is true unless that target is explicitly ' +
  'marked external, cache the answer for an hour';
const REPAIR_MOVE = 'move the sensitive integration to an internal-only channel. ' +
  'Where external posting is intended, redact for that target specifically rather ' +
  'than trusting the message body';

/**
 * Who this channel reaches, from conversations.info. Pure.
 * Four flags in precedence order, kept as separate verdicts: collapsing
 * org-shared into external fills the report with false alarms on Grid.
 */
export function sharingState(channel) {
  const fields = channel ?? {};
  const connected = (fields.connected_team_ids ?? []).filter(Boolean);
  const shared = (fields.shared_team_ids ?? []).filter(Boolean);

  if (fields.is_ext_shared === true) {
    return ['external',
      'is_ext_shared is set: this channel is shared with at least one organisation ' +
      `that is not yours (${connected.length} connected team id(s), ${shared.length} ` +
      'shared). Every message sent here leaves the company and nothing errors.'];
  }
  if (fields.is_pending_ext_shared === true) {
    return ['pending-external',
      'is_pending_ext_shared is set: an invitation to another organisation is ' +
      'outstanding. When it is accepted the channel ID stays the same and your ' +
      'integration is not asked anything.'];
  }
  if (fields.is_org_shared === true) {
    return ['org-shared',
      'is_org_shared is set: shared across workspaces inside one Enterprise Grid ' +
      'organisation. Internal, and it still breaks the assumption that every member ' +
      'carries your team_id.'];
  }
  if (fields.is_shared === true) {
    return ['shared-unclassified',
      'is_shared is set and neither is_ext_shared nor is_org_shared is. This token ' +
      'cannot tell which kind of sharing it is; treat it as external until a token ' +
      'that can see the org says otherwise.'];
  }
  return ['internal', 'no sharing flags are set on this channel'];
}

/**
 * Place one resolved member against your own workspace. Pure.
 * `user` is the users.info profile, or null when the call refused, and null is
 * the interesting case rather than an error.
 */
export function memberOrigin(user, homeTeamId) {
  if (!user) {
    return ['unresolvable',
      'users.info returned no profile. In a Connect channel that is the answer ' +
      'rather than a failure: this person belongs to another organisation and your ' +
      'token may not read them.'];
  }
  if (user.is_stranger === true) {
    return ['external-org',
      'is_stranger is set, which is Slack saying plainly that this member is ' +
      'outside your organisation.'];
  }

  const team = String(user.team_id ?? '');
  const home = String(homeTeamId ?? '');
  if (team && home && team !== home) {
    return ['other-team',
      `team_id ${team} is not ${home}. In an externally shared channel that is ` +
      'another organisation; in an org-shared channel it is another workspace in ' +
      'your own. The channel flags decide which, and this record does not.'];
  }
  if (user.is_ultra_restricted === true || user.is_restricted === true) {
    return ['guest',
      'a guest account in your own workspace. Inside the company, and usually ' +
      'without access to the channels your message links to.'];
  }
  if (user.is_bot === true) {
    return ['app', 'another app in the room, which reads everything you send'];
  }
  return ['home', 'a member of your own workspace'];
}

/**
 * What the send path still believes, for this sharing state. Pure.
 * Empty for an internal channel, which is the only clean output here.
 */
export function assumptionsBroken(state) {
  return [...(ASSUMPTIONS.get(state) ?? ASSUMPTIONS.get('shared-unclassified'))];
}

async function get(token, method, params) {
  const res = await fetch(`${API}${method}?${new URLSearchParams(params)}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  return res.json();
}

async function allMembers(token, cid, cap) {
  const out = [];
  let cursor = '';
  while (out.length < cap) {
    const params = { channel: cid, limit: '200' };
    if (cursor) params.cursor = cursor;
    const body = await get(token, 'conversations.members', params);
    if (body.ok !== true) {
      console.warn(`conversations.members on ${cid} answered ok: false, error=${body.error}`);
      return out;
    }
    out.push(...(body.members ?? []));
    cursor = body.response_metadata?.next_cursor ?? '';
    if (!cursor) break;
  }
  return out.slice(0, cap);
}

function arg(args, name, fallback = null) {
  const i = args.indexOf(name);
  return i === -1 ? fallback : args[i + 1];
}

function positionals(args) {
  const out = [];
  for (let i = 0; i < args.length; i += 1) {
    if (args[i].startsWith('--')) { i += 1; continue; }
    out.push(args[i]);
  }
  return out;
}

async function main() {
  const args = process.argv.slice(2);
  const channels = positionals(args);
  if (channels.length === 0) {
    console.error('usage: <channel id>... [--token-env SLACK_BOT_TOKEN] ' +
      '[--max-members 200]');
    process.exitCode = 2;
    return;
  }
  const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
  const token = process.env[tokenEnv];
  if (!token) {
    console.error(`set ${tokenEnv} (channels:read, groups:read and users:read are enough)`);
    process.exitCode = 2;
    return;
  }
  const cap = Number(arg(args, '--max-members', 200));

  const auth = await get(token, 'auth.test', {});
  if (auth.ok !== true) {
    throw new Error(`auth.test answered 200 with ok: false, error=${auth.error}`);
  }
  const home = auth.team_id;
  console.log(`identity   ${home} as ${auth.user_id}`);

  let outside = 0;
  for (const cid of channels) {
    const info = await get(token, 'conversations.info', { channel: cid });
    if (info.ok !== true) {
      console.warn(`${cid.padEnd(12)} sharing  unreadable, error=${info.error}`);
      continue;
    }

    const [state, detail] = sharingState(info.channel ?? {});
    const counts = new Map();
    for (const uid of await allMembers(token, cid, cap)) {
      const profile = (await get(token, 'users.info', { user: uid })).user ?? null;
      const [origin] = memberOrigin(profile, home);
      counts.set(origin, (counts.get(origin) ?? 0) + 1);
    }

    const externalMembers = (counts.get('external-org') ?? 0) + (counts.get('unresolvable') ?? 0);
    const summary = [...counts.entries()].sort()
      .map(([k, n]) => `${n} ${k}`).join(', ');

    if (state === 'internal' && !externalMembers) {
      console.log(`${cid.padEnd(12)} sharing  ${state.padEnd(20)} ${detail}`);
      console.log(`${cid.padEnd(12)} members  ${summary || 'none resolved'}`);
      continue;
    }

    outside += 1;
    console.warn(`${cid.padEnd(12)} sharing  ${state.padEnd(20)} ${detail}`);
    console.warn(`${cid.padEnd(12)} members  ${summary || 'none resolved'}`);
    for (const [assumption, reality] of assumptionsBroken(state)) {
      console.warn(`  assumes: ${assumption}`);
      console.warn(`  actually: ${reality}`);
    }
    console.warn(`  repair: ${REPAIR_ASSERT}`);
    console.warn(`  repair: ${REPAIR_MOVE}`);
  }

  console.log(`${channels.length} channel(s) checked, ${outside} reaching outside ` +
    'the organisation');
  process.exitCode = outside ? 1 : 0;
}

// Only run when invoked directly, so importing this module in the tests does not
// execute main() and fail the file on a missing token.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

Two behaviours carry this note and both are pinned hard. An org-shared channel must not be reported as external, because a Grid workspace has plenty of them and one false alarm per channel is how a report becomes wallpaper. And a member who fails to resolve must come back as unresolvable with an explanation, not as an exception: user_not_found for a member of a Connect channel is the answer to the question, not a failed call.

test_slack_external_channel_audit.py
from slack_external_channel_audit import (assumptions_broken, member_origin,
                                          sharing_state)

HOME = "T0ACME1111"
AWAY = "T0VENDOR22"


def test_an_externally_shared_channel_is_the_headline_finding():
    state, detail = sharing_state({"is_shared": True, "is_ext_shared": True,
                                   "connected_team_ids": [AWAY]})
    assert state == "external"
    assert "leaves the company" in detail


def test_a_pending_invitation_is_reported_before_it_is_accepted():
    state, detail = sharing_state({"is_pending_ext_shared": True})
    assert state == "pending-external"
    assert "not asked anything" in detail


def test_an_org_shared_channel_is_internal_and_not_an_alarm():
    state, detail = sharing_state({"is_shared": True, "is_org_shared": True})
    assert state == "org-shared"
    assert "Internal" in detail


def test_external_beats_org_shared_when_both_flags_are_set():
    assert sharing_state({"is_ext_shared": True, "is_org_shared": True})[0] == "external"


def test_bare_is_shared_is_not_classified_as_safe():
    state, _ = sharing_state({"is_shared": True})
    assert state == "shared-unclassified"
    assert assumptions_broken(state)


def test_a_channel_with_no_flags_is_internal_and_lists_nothing():
    state, _ = sharing_state({"id": "C0INTERNAL", "name": "deploys"})
    assert state == "internal"
    assert assumptions_broken(state) == []


def test_a_member_who_does_not_resolve_is_evidence_not_an_error():
    origin, why = member_origin(None, HOME)
    assert origin == "unresolvable"
    assert "rather than a failure" in why


def test_is_stranger_is_taken_at_face_value():
    origin, _ = member_origin({"id": "U0THEM111", "is_stranger": True}, HOME)
    assert origin == "external-org"


def test_a_foreign_team_id_is_reported_without_deciding_what_it_means():
    origin, why = member_origin({"id": "U0THEM111", "team_id": AWAY}, HOME)
    assert origin == "other-team"
    assert AWAY in why and HOME in why
    assert "channel flags decide" in why


def test_a_guest_in_your_own_workspace_is_not_external():
    origin, _ = member_origin({"id": "U0GUEST11", "team_id": HOME,
                               "is_restricted": True}, HOME)
    assert origin == "guest"


def test_another_app_in_the_room_is_named_as_one():
    origin, _ = member_origin({"id": "U0OTHERAP", "team_id": HOME,
                               "is_bot": True}, HOME)
    assert origin == "app"


def test_an_ordinary_colleague_is_home():
    assert member_origin({"id": "U0USER111", "team_id": HOME}, HOME)[0] == "home"


def test_the_external_assumption_list_names_the_email_and_the_mention():
    listed = " ".join(a for a, _ in assumptions_broken("external"))
    assert "users.info" in listed
    assert "email" in listed
    assert "mention" in listed


def test_an_unknown_state_is_treated_as_shared_rather_than_as_safe():
    assert assumptions_broken("something-new") == assumptions_broken("shared-unclassified")
slack-external-channel-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { assumptionsBroken, memberOrigin, sharingState }
  from './slack-external-channel-audit.mjs';

const HOME = 'T0ACME1111';
const AWAY = 'T0VENDOR22';

test('an externally shared channel is the headline finding', () => {
  const [state, detail] = sharingState({
    is_shared: true, is_ext_shared: true, connected_team_ids: [AWAY],
  });
  assert.equal(state, 'external');
  assert.match(detail, /leaves the company/);
});

test('a pending invitation is reported before it is accepted', () => {
  const [state, detail] = sharingState({ is_pending_ext_shared: true });
  assert.equal(state, 'pending-external');
  assert.match(detail, /not asked anything/);
});

test('an org shared channel is internal and not an alarm', () => {
  const [state, detail] = sharingState({ is_shared: true, is_org_shared: true });
  assert.equal(state, 'org-shared');
  assert.match(detail, /Internal/);
});

test('external beats org shared when both flags are set', () => {
  assert.equal(sharingState({ is_ext_shared: true, is_org_shared: true })[0], 'external');
});

test('bare is_shared is not classified as safe', () => {
  const [state] = sharingState({ is_shared: true });
  assert.equal(state, 'shared-unclassified');
  assert.ok(assumptionsBroken(state).length > 0);
});

test('a channel with no flags is internal and lists nothing', () => {
  const [state] = sharingState({ id: 'C0INTERNAL', name: 'deploys' });
  assert.equal(state, 'internal');
  assert.deepEqual(assumptionsBroken(state), []);
});

test('a member who does not resolve is evidence not an error', () => {
  const [origin, why] = memberOrigin(null, HOME);
  assert.equal(origin, 'unresolvable');
  assert.match(why, /rather than a failure/);
});

test('is_stranger is taken at face value', () => {
  assert.equal(memberOrigin({ id: 'U0THEM111', is_stranger: true }, HOME)[0],
    'external-org');
});

test('a foreign team id is reported without deciding what it means', () => {
  const [origin, why] = memberOrigin({ id: 'U0THEM111', team_id: AWAY }, HOME);
  assert.equal(origin, 'other-team');
  assert.match(why, new RegExp(AWAY));
  assert.match(why, new RegExp(HOME));
  assert.match(why, /channel flags decide/);
});

test('a guest in your own workspace is not external', () => {
  assert.equal(memberOrigin({ id: 'U0GUEST11', team_id: HOME, is_restricted: true },
    HOME)[0], 'guest');
});

test('another app in the room is named as one', () => {
  assert.equal(memberOrigin({ id: 'U0OTHERAP', team_id: HOME, is_bot: true },
    HOME)[0], 'app');
});

test('an ordinary colleague is home', () => {
  assert.equal(memberOrigin({ id: 'U0USER111', team_id: HOME }, HOME)[0], 'home');
});

test('the external assumption list names the email and the mention', () => {
  const listed = assumptionsBroken('external').map(([a]) => a).join(' ');
  assert.match(listed, /users.info/);
  assert.match(listed, /email/);
  assert.match(listed, /mention/);
});

test('an unknown state is treated as shared rather than as safe', () => {
  assert.deepEqual(assumptionsBroken('something-new'),
    assumptionsBroken('shared-unclassified'));
});

FAQ

How do I tell an externally shared channel from one shared between our own workspaces?

is_ext_shared means outside your organisation; is_org_shared means across workspaces inside one Enterprise Grid org. Both set is_shared, which is why is_shared alone is not a finding. The script keeps them apart deliberately: a Grid workspace has many org-shared channels, and an audit that flags all of them is an audit whose real finding gets scrolled past.

Why does users.info fail for some members of the channel?

Because they are not your users. A member from the other organisation is not in your workspace's user table, and your token has no entitlement to read them, so Slack answers user_not_found about a person who visibly exists. Treat that as the classification rather than as an error, and give the send path a branch for members it cannot resolve.

Can an app be notified when a channel becomes externally shared?

There are shared-channel events, but they require the subscription to have been set up in advance and the app's event delivery to be healthy, which is exactly the combination this note assumes you do not have. A scheduled read of conversations.info for every configured target does not depend on any of that and takes one call per channel.

Is posting to a Slack Connect channel always wrong?

No. Plenty of integrations exist to talk to a partner and belong there. The finding is the mismatch: an internal alerting bot, configured for an internal channel, still posting after the channel was shared. That is why the durable repair is an explicit per-target flag, so an intentionally external target passes the assertion and everything else fails it.

Nothing has ever errored. What is the actual harm?

Whatever your alerts contain. Deploy failures carry stack traces, stack traces carry file paths and query fragments, and monitoring messages carry customer names and IDs. None of that was written with an external reader in mind, because when it was written there was not one. The absence of an error is what let it run for months.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.