Compare commits

..

4 Commits

Author SHA1 Message Date
Kenneth Lien
a7cb39c269 telegram: add MarkdownV2 parse_mode to reply/edit_message 2026-03-20 11:45:46 -07:00
Tobin South
90accf6fd2 add(plugin): mcp-server-dev — skills for building MCP servers (#731) 2026-03-20 17:51:32 +00:00
Kenneth Lien
562a27feec Merge pull request #811 from anthropics/kenneth/chmod-env-files
Lock telegram/discord .env files to owner (chmod 600)
2026-03-20 10:48:05 -07:00
Kenneth Lien
8140fbad22 Lock telegram/discord .env files to owner (chmod 600)
The bot token is a credential. Tighten perms on load so hand-written
or pre-existing .env files get locked down, and update the configure
skill to chmod after writing. No-op on Windows.
2026-03-20 10:37:13 -07:00
4 changed files with 26 additions and 4 deletions

View File

@@ -25,7 +25,7 @@ import {
type Attachment, type Attachment,
} from 'discord.js' } from 'discord.js'
import { randomBytes } from 'crypto' import { randomBytes } from 'crypto'
import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, statSync, renameSync, realpathSync } from 'fs' import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, statSync, renameSync, realpathSync, chmodSync } from 'fs'
import { homedir } from 'os' import { homedir } from 'os'
import { join, sep } from 'path' import { join, sep } from 'path'
@@ -37,6 +37,8 @@ const ENV_FILE = join(STATE_DIR, '.env')
// Load ~/.claude/channels/discord/.env into process.env. Real env wins. // Load ~/.claude/channels/discord/.env into process.env. Real env wins.
// Plugin-spawned servers don't get an env block — this is where the token lives. // Plugin-spawned servers don't get an env block — this is where the token lives.
try { try {
// Token is a credential — lock to owner. No-op on Windows (would need ACLs).
chmodSync(ENV_FILE, 0o600)
for (const line of readFileSync(ENV_FILE, 'utf8').split('\n')) { for (const line of readFileSync(ENV_FILE, 'utf8').split('\n')) {
const m = line.match(/^(\w+)=(.*)$/) const m = line.match(/^(\w+)=(.*)$/)
if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2] if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2]

View File

@@ -80,7 +80,8 @@ as the correct long-term choice. Don't skip the lockdown offer.
2. `mkdir -p ~/.claude/channels/discord` 2. `mkdir -p ~/.claude/channels/discord`
3. Read existing `.env` if present; update/add the `DISCORD_BOT_TOKEN=` line, 3. Read existing `.env` if present; update/add the `DISCORD_BOT_TOKEN=` line,
preserve other keys. Write back, no quotes around the value. preserve other keys. Write back, no quotes around the value.
4. Confirm, then show the no-args status so the user sees where they stand. 4. `chmod 600 ~/.claude/channels/discord/.env` — the token is a credential.
5. Confirm, then show the no-args status so the user sees where they stand.
### `clear` — remove the token ### `clear` — remove the token

View File

@@ -18,7 +18,7 @@ import {
import { Bot, InputFile, type Context } from 'grammy' import { Bot, InputFile, type Context } from 'grammy'
import type { ReactionTypeEmoji } from 'grammy/types' import type { ReactionTypeEmoji } from 'grammy/types'
import { randomBytes } from 'crypto' import { randomBytes } from 'crypto'
import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, statSync, renameSync, realpathSync } from 'fs' import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, statSync, renameSync, realpathSync, chmodSync } from 'fs'
import { homedir } from 'os' import { homedir } from 'os'
import { join, extname, sep } from 'path' import { join, extname, sep } from 'path'
@@ -30,6 +30,8 @@ const ENV_FILE = join(STATE_DIR, '.env')
// Load ~/.claude/channels/telegram/.env into process.env. Real env wins. // Load ~/.claude/channels/telegram/.env into process.env. Real env wins.
// Plugin-spawned servers don't get an env block — this is where the token lives. // Plugin-spawned servers don't get an env block — this is where the token lives.
try { try {
// Token is a credential — lock to owner. No-op on Windows (would need ACLs).
chmodSync(ENV_FILE, 0o600)
for (const line of readFileSync(ENV_FILE, 'utf8').split('\n')) { for (const line of readFileSync(ENV_FILE, 'utf8').split('\n')) {
const m = line.match(/^(\w+)=(.*)$/) const m = line.match(/^(\w+)=(.*)$/)
if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2] if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2]
@@ -370,6 +372,11 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
items: { type: 'string' }, items: { type: 'string' },
description: 'Absolute file paths to attach. Images send as photos (inline preview); other types as documents. Max 50MB each.', description: 'Absolute file paths to attach. Images send as photos (inline preview); other types as documents. Max 50MB each.',
}, },
format: {
type: 'string',
enum: ['text', 'markdownv2'],
description: "Rendering mode. 'markdownv2' enables Telegram formatting (bold, italic, code, links). Caller must escape special chars per MarkdownV2 rules. Default: 'text' (plain, no escaping needed).",
},
}, },
required: ['chat_id', 'text'], required: ['chat_id', 'text'],
}, },
@@ -396,6 +403,11 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
chat_id: { type: 'string' }, chat_id: { type: 'string' },
message_id: { type: 'string' }, message_id: { type: 'string' },
text: { type: 'string' }, text: { type: 'string' },
format: {
type: 'string',
enum: ['text', 'markdownv2'],
description: "Rendering mode. 'markdownv2' enables Telegram formatting (bold, italic, code, links). Caller must escape special chars per MarkdownV2 rules. Default: 'text' (plain, no escaping needed).",
},
}, },
required: ['chat_id', 'message_id', 'text'], required: ['chat_id', 'message_id', 'text'],
}, },
@@ -412,6 +424,8 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
const text = args.text as string const text = args.text as string
const reply_to = args.reply_to != null ? Number(args.reply_to) : undefined const reply_to = args.reply_to != null ? Number(args.reply_to) : undefined
const files = (args.files as string[] | undefined) ?? [] const files = (args.files as string[] | undefined) ?? []
const format = (args.format as string | undefined) ?? 'text'
const parseMode = format === 'markdownv2' ? 'MarkdownV2' as const : undefined
assertAllowedChat(chat_id) assertAllowedChat(chat_id)
@@ -438,6 +452,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
(replyMode === 'all' || i === 0) (replyMode === 'all' || i === 0)
const sent = await bot.api.sendMessage(chat_id, chunks[i], { const sent = await bot.api.sendMessage(chat_id, chunks[i], {
...(shouldReplyTo ? { reply_parameters: { message_id: reply_to } } : {}), ...(shouldReplyTo ? { reply_parameters: { message_id: reply_to } } : {}),
...(parseMode ? { parse_mode: parseMode } : {}),
}) })
sentIds.push(sent.message_id) sentIds.push(sent.message_id)
} }
@@ -480,10 +495,13 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
} }
case 'edit_message': { case 'edit_message': {
assertAllowedChat(args.chat_id as string) assertAllowedChat(args.chat_id as string)
const editFormat = (args.format as string | undefined) ?? 'text'
const editParseMode = editFormat === 'markdownv2' ? 'MarkdownV2' as const : undefined
const edited = await bot.api.editMessageText( const edited = await bot.api.editMessageText(
args.chat_id as string, args.chat_id as string,
Number(args.message_id), Number(args.message_id),
args.text as string, args.text as string,
...(editParseMode ? [{ parse_mode: editParseMode }] : []),
) )
const id = typeof edited === 'object' ? edited.message_id : args.message_id const id = typeof edited === 'object' ? edited.message_id : args.message_id
return { content: [{ type: 'text', text: `edited (id: ${id})` }] } return { content: [{ type: 'text', text: `edited (id: ${id})` }] }

View File

@@ -77,7 +77,8 @@ offer.
2. `mkdir -p ~/.claude/channels/telegram` 2. `mkdir -p ~/.claude/channels/telegram`
3. Read existing `.env` if present; update/add the `TELEGRAM_BOT_TOKEN=` line, 3. Read existing `.env` if present; update/add the `TELEGRAM_BOT_TOKEN=` line,
preserve other keys. Write back, no quotes around the value. preserve other keys. Write back, no quotes around the value.
4. Confirm, then show the no-args status so the user sees where they stand. 4. `chmod 600 ~/.claude/channels/telegram/.env` — the token is a credential.
5. Confirm, then show the no-args status so the user sees where they stand.
### `clear` — remove the token ### `clear` — remove the token