Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -472,10 +472,16 @@ agent-slack user list --workspace "https://workspace.slack.com" --limit 200 | jq
agent-slack user get U12345678 --workspace "https://workspace.slack.com" | jq .
agent-slack user get "@alice" --workspace "https://workspace.slack.com" | jq .

# Verify active humans before constructing Slack mentions
agent-slack user resolve U12345678 bob@example.com \
--workspace "https://workspace.slack.com"

# Open a DM or group DM with one to eight other users (the caller is implicit)
agent-slack user dm-open "@alice" "@bob" --workspace "https://workspace.slack.com" | jq .
```

`user resolve` accepts only canonical U/W user IDs and email addresses. It uses direct lookups and emits mentions only when every input resolves to an active human; otherwise it exits nonzero and emits none.

### Unreads (inbox view)

See all unread messages across channels, DMs, and threads in one place:
Expand Down
3 changes: 2 additions & 1 deletion skills/agent-slack/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: agent-slack
description: "Slack CLI for agents: read URLs/threads/history/unreads/later/canvases/workflows, create canvases from Markdown, search messages/files, download attachments, lookup users, list/create/invite channels, open DMs, compose messages, manage Slack-native drafts, schedule sends, and explicit sends/edits/deletes/reactions/mark-read/uploads."
description: "Slack CLI for agents: read URLs/threads/history/unreads/later/canvases/workflows, create canvases from Markdown, search messages/files, download attachments, lookup users, resolve verified human mentions, list/create/invite channels, open DMs, compose messages, manage Slack-native drafts, schedule sends, and explicit sends/edits/deletes/reactions/mark-read/uploads."
---

# agent-slack
Expand All @@ -20,6 +20,7 @@ If a capability named here is absent from installed help, report version skew in

- Read and search freely.
- Perform write actions only when explicitly requested: sends, edits, deletes, reactions, invitations, channel or canvas creation, mark-read operations, scheduling or canceling delivery, uploads, Later state/reminder changes, DM/group-DM creation, and `workflow run`. Workflow runs can execute downstream actions.
- Never scan the full user directory to resolve a mention. `user resolve` accepts canonical user IDs and emails; use its mentions only when `safe_to_mention` is true.
- For compose- or review-only requests, return proposed text without invoking Slack, or use `message draft create` to add a Slack-native draft the user can review and send (nothing is posted). `message compose` is send-capable; use it only when the user explicitly asks to open the interactive editor. In CI or another noninteractive environment, do not invoke it without separate authorization to send immediately: CI skips the editor and sends supplied text.
- With `AGENT_SLACK_SAFE_MODE=1` (or the global `--safe-mode` flag) set, safe mode is enforced at the tool level: `message send` is redirected to the draft editor and `message edit`/`message delete` are blocked. Use it when nothing should post without human review.

Expand Down
56 changes: 56 additions & 0 deletions src/cli/user-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ import type { Command } from "commander";
import type { CliContext } from "./context.ts";
import { pruneEmpty } from "../lib/compact-json.ts";
import { getDmChannelForUsers, getUser, listUsers } from "../slack/users.ts";
import {
resolveStrictUserIdentities,
type UserResolution,
} from "../slack/strict-user-resolution.ts";

const USER_RESOLUTION_ERROR = "Unable to resolve users safely.";
const SLACK_WORKSPACE_HOST =
/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+(?:slack\.com|slack-gov\.com)$/;

export function registerUserCommand(input: { program: Command; ctx: CliContext }): void {
const userCmd = input.program.command("user").description("Workspace user directory");
Expand Down Expand Up @@ -41,6 +49,37 @@ export function registerUserCommand(input: { program: Command; ctx: CliContext }
}
});

userCmd
.command("resolve")
.description("Verify active humans by Slack user ID or email")
.argument("<identities...>", "Canonical U/W user IDs or email addresses")
.option(
"--workspace <url>",
"Workspace selector (full URL or unique substring; required if you have multiple workspaces)",
)
.action(async (...args) => {
const [identities, options] = args as [string[], { workspace?: string }];
try {
const workspaceUrl = input.ctx.effectiveWorkspaceUrl(options.workspace);
const output = await input.ctx.withAutoRefresh({
workspaceUrl,
work: async () => {
const { client, workspace_url } = await input.ctx.getClientForWorkspace(workspaceUrl);
const workspace = requireSlackWorkspaceOrigin(workspace_url);
const resolution = await resolveStrictUserIdentities({ client, identities });
return { workspace, resolution };
},
});
printUserResolution(output.workspace, output.resolution);
if (!output.resolution.safe_to_mention) {
process.exitCode = 1;
}
} catch {
console.error(USER_RESOLUTION_ERROR);
process.exitCode = 1;
}
});

userCmd
.command("get")
.description("Get a single workspace user")
Expand Down Expand Up @@ -90,3 +129,20 @@ export function registerUserCommand(input: { program: Command; ctx: CliContext }
}
});
}

function requireSlackWorkspaceOrigin(workspaceUrl: string | undefined): string {
const url = workspaceUrl && URL.canParse(workspaceUrl) ? new URL(workspaceUrl) : null;
if (
!url ||
url.protocol !== "https:" ||
url.origin !== workspaceUrl ||
!SLACK_WORKSPACE_HOST.test(url.hostname)
) {
throw new Error("Resolved workspace is not a canonical Slack origin");
}
return workspaceUrl;
}

function printUserResolution(workspace: string, resolution: UserResolution): void {
console.log(JSON.stringify(pruneEmpty({ workspace, ...resolution }), null, 2));
}
122 changes: 122 additions & 0 deletions src/slack/strict-user-resolution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { isRecord } from "../lib/object-type-guards.ts";
import type { SlackApiClient } from "./client.ts";
import { isUserId } from "./user-id.ts";

type Identity = { kind: "id"; value: string } | { kind: "email"; value: string };

type ResolutionResult = {
index: number;
status: "resolved" | "unresolved";
mention?: `<@${string}>`;
};

type InternalResult = ResolutionResult & { userId?: string };

export type UserResolution = {
safe_to_mention: boolean;
results: ResolutionResult[];
};

const EMAIL_PATTERN = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;

export async function resolveStrictUserIdentities(input: {
client: SlackApiClient;
identities: string[];
}): Promise<UserResolution> {
if (input.identities.length === 0) {
throw new Error("At least one user identity is required");
}

const identities = input.identities.map(parseIdentity);
const results: InternalResult[] = [];

for (const [index, identity] of identities.entries()) {
let response: Record<string, unknown>;
try {
response =
identity.kind === "id"
? await input.client.api("users.info", { user: identity.value })
: await input.client.api("users.lookupByEmail", { email: identity.value });
} catch (error) {
if (isNotFoundError(error, identity.kind)) {
results.push({ index, status: "unresolved" });
continue;
}
throw error;
}

const userId = parseVerifiedUserId(response.user);
const matchesInput = userId && (identity.kind === "email" || userId === identity.value);
results.push(
matchesInput ? { index, status: "resolved", userId } : { index, status: "unresolved" },
);
}

if (results.every((result) => result.status === "resolved")) {
return {
safe_to_mention: true,
results: results.map((result) => ({
index: result.index,
status: "resolved",
mention: `<@${result.userId!}>`,
})),
};
}

return {
safe_to_mention: false,
results: results.map(({ index, status }) => ({ index, status })),
};
}

function parseIdentity(input: string, index: number): Identity {
const value = input.trim();
if (isUserId(value)) {
return { kind: "id", value };
}
if (EMAIL_PATTERN.test(value)) {
return { kind: "email", value: value.toLowerCase() };
}
throw new Error(`User identity at index ${index} must be a canonical U/W ID or email`);
}

function parseVerifiedUserId(value: unknown): string | null {
if (!isRecord(value) || Array.isArray(value)) {
return null;
}
const id = typeof value.id === "string" && isUserId(value.id) ? value.id : null;
const profile = isRecord(value.profile) && !Array.isArray(value.profile) ? value.profile : null;
if (!id || id === "USLACKBOT" || !profile || value.deleted !== false || value.is_bot !== false) {
return null;
}

const inactiveOrBotSignals = [
value.is_connector_bot,
value.is_workflow_bot,
value.is_agentforce_bot,
value.is_invited_user,
value.suspended,
value.is_forgotten,
profile.is_agentforce_bot,
profile.is_sidekick_bot,
];
if (inactiveOrBotSignals.some((signal) => signal != null && signal !== false)) {
return null;
}
if (profile.bot_id != null && profile.bot_id !== "") {
return null;
}

return id;
}

function isNotFoundError(error: unknown, kind: Identity["kind"]): boolean {
const expected = kind === "id" ? "user_not_found" : "users_not_found";
if (error instanceof Error && error.message === expected) {
return true;
}
if (!isRecord(error) || !isRecord(error.data) || Array.isArray(error.data)) {
return false;
}
return error.data.error === expected;
}
Loading
Loading