slack-term 1.41.0 → 1.41.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slack-term",
3
- "version": "1.41.0",
3
+ "version": "1.41.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/slack-term.git"
@@ -0,0 +1,68 @@
1
+ // Refuse to act as the USER when the caller clearly meant the BOT.
2
+ //
3
+ // SLACK_BOT_TOKEN is only ever used by `--as-bot`: its documented home is the
4
+ // global ~/.config/slack-cli/.env, where it sits for `slack ask` and friends,
5
+ // and it must not turn every post into a bot post. But a lane that EXPORTS it
6
+ // (or puts it in its project's .env) without SLACK_TOKEN is saying "I am the
7
+ // bot" — and without --as-bot every write still went out under the logged-in
8
+ // person's session, silently (reported 2026-10-26: a lane posted a pinlog as
9
+ // the user). In that case writes are refused until the caller picks a side.
10
+
11
+ /** Write commands, as `argv._` spells them (aliases included). Read-only
12
+ * commands are never refused — reading as the user impersonates no one. */
13
+ const WRITES = new Set([
14
+ "send", "ask", "poll", "vote", "edit", "delete", "react", "upload",
15
+ "schedule send", "schedule rm",
16
+ "pinlog new", "pinlog set", "pinlog note", "pinlog pin",
17
+ "todo set", "todo flag",
18
+ "lists create", "lists add", "lists set", "lists share",
19
+ "drafts new", "drafts save", "drafts edit", "drafts update", "drafts delete", "drafts rm",
20
+ "channel create", "channel new", "channel rename", "channel mv",
21
+ ]);
22
+
23
+ /** `argv._` holds what was TYPED, so top-level aliases must be folded first. */
24
+ const TOP_ALIAS: Record<string, string> = { channels: "channel", ch: "channel" };
25
+
26
+ export function isWriteCommand(path: (string | number)[]): boolean {
27
+ const [raw = "", b = ""] = path.map(String);
28
+ const a = TOP_ALIAS[raw] ?? raw;
29
+ return WRITES.has(a) || WRITES.has(`${a} ${b}`);
30
+ }
31
+
32
+ /** Read-only modes of a write command. `ask --ls` only reads the local
33
+ * sent-log and the questions' live state. (`ask --waitFor` and
34
+ * `poll --results` are NOT here: both can edit the posted message.) */
35
+ function isReadMode(path: (string | number)[], argv: Record<string, unknown>): boolean {
36
+ return String(path[0] ?? "") === "ask" && argv.ls === true;
37
+ }
38
+
39
+ export type BotIntent = {
40
+ /** Where SLACK_BOT_TOKEN (an xoxb-) came from: "shell", a dotenv path, or undefined when unset. */
41
+ botTokenSource: string | undefined;
42
+ /** dotenv paths that are the bot token's GLOBAL home — not a statement of intent. */
43
+ globalPaths: string[];
44
+ slackTokenSet: boolean;
45
+ /** The token this command would act with is itself the bot token (no profiles). */
46
+ activeTokenIsBot: boolean;
47
+ };
48
+
49
+ /** A refusal message, or null when the write may proceed. */
50
+ export function botIntentRefusal(
51
+ cmd: (string | number)[],
52
+ argv: Record<string, unknown>,
53
+ intent: BotIntent,
54
+ ): string | null {
55
+ const src = intent.botTokenSource;
56
+ if (!src || intent.globalPaths.includes(src)) return null;
57
+ if (intent.slackTokenSet || intent.activeTokenIsBot) return null;
58
+ if (!isWriteCommand(cmd) || isReadMode(cmd, argv) || argv["as-bot"] === true) return null;
59
+ const from = src === "shell" ? "exported in the environment" : `set in ${src}`;
60
+ const canBot = "as-bot" in argv;
61
+ return [
62
+ `Error: refusing to act as YOU — SLACK_BOT_TOKEN is ${from}, but \`${cmd.slice(0, 2).join(" ")}\` would use your user session, not the bot.`,
63
+ canBot
64
+ ? " As the app: add --as-bot"
65
+ : " This command has no --as-bot: it can only act as a user.",
66
+ " As yourself: set SLACK_TOKEN, or clear the bot token for this run (SLACK_BOT_TOKEN= slack ...)",
67
+ ].join("\n");
68
+ }
package/ts/cli.ts CHANGED
@@ -130,11 +130,21 @@ import {
130
130
  import { setCacheEnabled } from "./cache.ts";
131
131
  import { escapeArg, unescapeArg } from "./escapes.ts";
132
132
  import { attachmentLines, parseAttachments } from "./attachments.ts";
133
+ import { botIntentRefusal } from "./botIntent.ts";
133
134
  import { dayLabel, encodeMentions, encodeMentionsDetailed, findUntaggedMentions, formatYmdHm, maskSecrets, mentionWarnings, resolveDateMarkup, resolveMentions, type MentionEncodeResult } from "./format.ts";
134
135
  import { quietHoursNotice } from "./quietHours.ts";
135
136
  import { installSyncConsole } from "./stdout.ts";
136
137
  import { LockBusyError, PINLOG_MARKER, acquireLock, composeHead, formatJst, headUpdatedAt, isPinlogHead, parsePinlogId, pinlogId, stripPinlogFooter } from "./pinlog.ts";
137
138
 
139
+ // Which dotenv file supplied each variable (absent = the shell's own env).
140
+ const dotenvSource = new Map<string, string>();
141
+ // Every value a GLOBAL dotenv holds, even when the shell already had the key.
142
+ // A child of `slack agent` (or any process the CLI spawned) inherits the
143
+ // global SLACK_BOT_TOKEN as a plain env var; matching it back to its global
144
+ // file keeps that inherited copy from reading as an explicit export.
145
+ const globalDotenvValue = new Map<string, { path: string; val: string }[]>();
146
+ const GLOBAL_DOTENVS = [join(homedir(), ".slack-cli", ".env.local"), join(homedir(), ".config/slack-cli", ".env")];
147
+
138
148
  function loadDotenv(path: string): void {
139
149
  if (!existsSync(path)) return;
140
150
  const text = readFileSync(path, "utf8");
@@ -148,7 +158,13 @@ function loadDotenv(path: string): void {
148
158
  if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
149
159
  val = val.slice(1, -1);
150
160
  }
151
- if (!(key in process.env)) process.env[key] = val;
161
+ if (GLOBAL_DOTENVS.includes(path)) {
162
+ globalDotenvValue.set(key, [...(globalDotenvValue.get(key) ?? []), { path, val }]);
163
+ }
164
+ if (!(key in process.env)) {
165
+ process.env[key] = val;
166
+ dotenvSource.set(key, path);
167
+ }
152
168
  }
153
169
  }
154
170
 
@@ -160,8 +176,7 @@ function ensureSlackCliDir(dir: string): void {
160
176
 
161
177
  function loadDotenvFiles(): void {
162
178
  // Global home config
163
- loadDotenv(join(homedir(), ".slack-cli", ".env.local"));
164
- loadDotenv(join(homedir(), ".config/slack-cli", ".env"));
179
+ for (const p of GLOBAL_DOTENVS) loadDotenv(p);
165
180
  // Local project overrides (highest priority — loaded last so they win)
166
181
  loadDotenv(join(process.cwd(), ".slack-cli", ".env.local"));
167
182
  loadDotenv(join(process.cwd(), ".env.local"));
@@ -4965,7 +4980,22 @@ async function main(): Promise<void> {
4965
4980
  // `sent` reads only the local log — no token, no network.
4966
4981
  if (!cmd || cmd === "auth" || cmd === "login" || cmd === "agent" || cmd === "sent") return;
4967
4982
  try {
4968
- resolveToken((argv as W).workspace);
4983
+ const active = resolveToken((argv as W).workspace);
4984
+ const bot = resolveBotToken();
4985
+ const refusal = botIntentRefusal(argv._ ?? [], argv as Record<string, unknown>, {
4986
+ botTokenSource: bot
4987
+ ? dotenvSource.get("SLACK_BOT_TOKEN")
4988
+ ?? globalDotenvValue.get("SLACK_BOT_TOKEN")?.find((g) => g.val === bot)?.path
4989
+ ?? "shell"
4990
+ : undefined,
4991
+ globalPaths: GLOBAL_DOTENVS,
4992
+ slackTokenSet: !!process.env.SLACK_TOKEN,
4993
+ activeTokenIsBot: !!bot && active === bot,
4994
+ });
4995
+ if (refusal) {
4996
+ console.error(refusal);
4997
+ process.exit(2);
4998
+ }
4969
4999
  } catch (e) {
4970
5000
  const msg = e instanceof Error ? e.message : String(e);
4971
5001
  if (msg.startsWith("No profiles configured")) {