Repository navigation
Releases: fidpa/cc-telegram-bot
Release list
v1.3.6: Refusal notices appear once and /m o selects what it names
Two chat-visible fixes in the refusal path, one model-switch cleanup, and a
README wording pass. No configuration changes.
Fixed
- Blocked-command notices no longer stack two prefixes.
checkToolCallSafety()
added "BLOCKED: " on top of the "Blocked: " thatcheckCommandSafety()already
supplies, so a denied Bash command read as "BLOCKED: Blocked: reading .env file
via shell" in the chat. Every branch insrc/security.tsnow owns exactly one
prefix - Each distinct refusal wording reaches the chat once per turn. A denied tool
call is not fatal: the model retries around the obstacle, so the same rule can
trip several times on the way to one answer, and the repeated identical notice
read as a malfunction. Deduplicated per turn insrc/session.ts; the
server-side log keeps every occurrence
Changed
/m oselectsclaude-opus-4-6, and the help text says so. Before, three
places disagreed: the shortcut selectedclaude-opus-5, the help and error
text advertised "Opus 4.8", and the display-name map carried both Opus entries.
One model id now appears in the shortcut map, the display names, and both
user-facing texts (src/handlers/commands.ts). The default model
(CLAUDE_MODEL,claude-sonnet-5) is untouched- The README drops its template intro, dash asides, and unmeasured counts.
Wording pass against the workbench README quality standards: 42 double-hyphen
asides replaced with regular punctuation, the problem-intro template rewritten
as plain prose, the absolute claim that four layers "ensure secrets never
reach the user" reworded (the documented limitations say otherwise), and the
maintained line counts removed from the footer. Documentation only
Upgrade notes
Nothing to do. /m o now hands new turns to Opus 4.6 instead of Opus 5; pick
the model explicitly with /m if that matters to your workflow.
v1.3.5: The editorial section states what holds, not how much did not
A release page says what now holds. How many sections a check found wanting, and which
internal rule they had been written against, address the maintainer rather than the
reader, and they push the corrections themselves into the background. The rule this
repository follows now is that the tally stays out of the release page while every
correction keeps its own entry, with its effect and its anchor in the code.
Documentation only. No source file, no dependency and no configuration was touched.
Changed
-
The
[1.3.4]section opens with what was put right, not with a count of what was
wrong. Its introduction carried the number of sections that had described the wrong
tag, together with the evidence for it, which the first entry below already gives in
full. The introduction now states the pass and points at the entries. Every individual
correction stays exactly where it was, with its effect and its tag or file reference. -
The entry on typography no longer cites an internal rule file. It measured the em
dashes against.claude/commands/release.md, which is not part of the published
repository and says nothing to a reader. What the entry reports is the change itself:
the characters are gone and what replaced them.
Upgrade notes
Nothing to do. This release changes changelog text only.
v1.3.4: Release notes describe the tag they are published under
Every entry in this file was rewritten to lead with what changed for an operator. Sections
that described something other than the tag they sit under were corrected against the tags
themselves; the corrections are listed below.
No tag was moved and no released code changed. This is a documentation release.
Fixed
- The
[1.1.0]section now describes what thev1.1.0tag shipped. Reconstructed from
the published release body, which had it right all along, and verified againstgit diff v1.0.0 v1.1.0: four files, none of them the alert socket. What the section listed before
is preserved in[1.0.0], where it belongs, marked as predecessor history - The
[1.0.0]section said 18 security layers where the release shipped 24. Counted in
the tag itself:git show v1.0.0:README.mdcarries a 24-row layer table, and
docs/SECURITY_AUDIT_REPORT.mdnumbers its findings up toSEC-F15. The figure 18 was
the count before the last hardening round of the predecessor, not the count that shipped - Both early sections were dated 2026-02-16, which is neither tag date.
v1.0.0is
tagged 2026-02-19 andv1.1.02026-05-30. The headings now carry the tag dates, and the
three-month gap between the two is no longer hidden behind an identical date - Every entry now opens with a bold line stating the effect. All 108 entries were plain
prose before, so a reader scanning the bold lines of a release got nothing. The cause,
the measurement and the code anchor follow in the paragraph below, unchanged - The em dashes and the ellipsis are gone. Ten em dashes and one ellipsis had entered
the 1.2.3, 1.3.1 and 1.3.2 sections. Replaced by the punctuation or the word each stood
for
Changed
- Release titles carry a headline after the colon instead of repeating the version. All
ten published releases were titled with the bare number, which the release list already
shows next to them. The headline is drawn from the section rather than invented
Security
- Nothing about the running bot changed. No source file, no dependency and no
configuration was touched. Every measured figure, path, function name and version
reference in the older sections was verified against its tag and left as it stood, except
the layer count named above
v1.3.3: Unsafe tool calls are denied before they run
Security
- An unsafe tool call is now prevented, not merely reported after the fact. A
PreToolUsehook (buildSafetyHook()insrc/security.ts) runscheckToolCallSafety()
before the call executes and denies it withpermissionDecision: "deny", which resolves
beforecanUseTooland is therefore not overridden by
permissionMode: "bypassPermissions". This covers the command blocklist (Layers 5/6),
file and search path validation (Layer 7) and theALLOWED_TOOLS/DISALLOWED_TOOLS
lists, and it fires inside sub-agents as well (HookInput.agent_id). 1.3.0 named this as
the missing piece and deferred it as its own change; this is that change. The hook fails
closed: anything thrown inside the callback still denies - The layer count stays at 24. Nothing new is defended here. The same four checks now
act where they previously only observed, and inventing a Layer 25 for a relocated
enforcement point would overstate what changed.docs/SECURITY_AUDIT_REPORT.mdis a dated
document and is left untouched
Fixed
- A blocked tool call no longer costs the user the whole answer. The stream check threw,
which aborted the query: the user asked a question, the model reached for a route it was
not allowed to take, and no answer ever arrived, with only the generic error to show for
it. The denial now goes back to the model as a tool result and it picks another route. The
stream check reports and continues. Measured against SDK 0.3.233, thetool_useblock
reaches the output stream before the hook fires, so the stream check cannot serve as a
fallback and must not throw; prevention lives in the hook alone - The 1.3.2 release stopped after the tag, leaving the version and the changelog behind.
package.jsonstill read1.3.1after the 1.3.2 release, andCHANGELOG.mdhad no
[1.3.2]section: its entries were left under[Unreleased]. The section has been written
from the 1.3.2 commit and the version now reads1.3.3. Thev1.3.2tag itself was never
pushed and is published unchanged rather than moved; thepackage.jsonit carries still
says1.3.1, which cannot be corrected without rewriting history. Same failure mode as the
drift that 1.2.0 had to clean up - Three documents described tool-call checking as it behaved before this release. They
asserted that it "reports, it does not prevent" and that no hook was installed:README.md
(Tool Control),SECURITY.md(bypass mode) anddocs/security-limitations.md
(limitation 4, now marked closed and kept in the list rather than deleted, as limitation 8
was in 1.3.1) src/session.tshad crossed the 800-line limit thatCONTRIBUTING.mdsets. It stood
at 806 lines, because the new comment block duplicated the one it replaced. Condensed to
797- The size claim in
README.mdandAGENTS.mdwas 200 lines short. Corrected from
"~6,800 lines TypeScript" to ~7,000; measured acrosssrc/andask_user_mcp/: 7,052
v1.3.2: A reasoning baseline that does not depend on remembering a keyword
Added
- A reasoning level can now be set once instead of per message.
THINKING_DEFAULT
(offby default = previous behaviour) is the baseline thinking level for every message,
applied before keywords. Until now the only way to get reasoning effort was a keyword in
the message, so a bot whose turns are judgement calls rather than commands depended on the
operator remembering one every time, which is the kind of discipline that fails on the
message where it mattered.normalmaps toeffort: high,deeptoeffort: max.
Keywords still apply on top and can only raise the level, never lower it: adeepbaseline
staysdeepeven when the message contains anormalkeyword, since a floor that wording
can undo is not a floor
Changed
/model onow selects Opus 5 instead of Opus 4.8. Opus 4.8 stays in the display-name
table so an existingCLAUDE_MODELpointing at it is still shown by name rather than as a
raw ID
Fixed
-
Two lines in
.env.examplestopped the bot from starting as a LaunchAgent. They broke
source .env, which is howlaunchagent/start.shreads the file. Uncommenting them and
starting the bot producedcommand not found: areandcommand not found: theinstead of
a running bot. Every other multi-word value in the file was already quoted; these two were
not:- the
BOT_PERSONAexample, added in 1.2.3, now quoted, with a note saying why - a prose line in the
ALLOWED_TOOLSsection that happened to wrap onto
# ALLOWED_TOOLS=Read,Grep,Glob the built-ins reduce..., so a sentence read as an
assignment. Reflowed. It also looked like a variable to the CI documentation gate, which
greps^#? ?[A-Z_]{4,}=; it passed only becauseALLOWED_TOOLSis a real variable, and
a prose line starting with any other uppercase token would have failed the gate with a
confusing message
Found while configuring a bot built on this code, not by review: the failure surfaces on
the LaunchAgent path and not when runningbun run startby hand - the
v1.3.1: Two users at once no longer race on the ask_user handshake
Fixed
- An inline keyboard could reach the wrong chat when two users asked at the same moment.
The ask_user subprocess read the sharedquery-context.jsonand never the per-user
query-context-{userId}.jsonthe bot wrote beside it, because it had no way to know which
user it was serving. The second request overwrote the file before the first subprocess read
it. The bot now injectsASK_USER_USER_IDper query (mcpServersForUser()), which works
because the SDK spawns stdio MCP servers perquery()call, verified against 0.3.233 that
two spawns carry different values without leaking. The subprocess prefers the per-user file
and falls back to the shared one, so an older subprocess keeps working. Measured before and
after: with two users' contexts written in sequence the old path returned the second user's
chat ID for both turns, the new one returns each user's own BOT_IDdid not reach the ask_user handshake, so two instances shared it. The
handshake directory was still hardcoded to~/.cc-telegram-bot/ask-userin four places
(src/session.ts,src/handlers/streaming.ts,src/handlers/callback.ts,
ask_user_mcp/server.ts).query-context.jsonin that directory is the one handshake file
that is not per user, so instance A could answer a button press with instance B's chat
context. Now derived fromBOT_IDvia the newASK_USER_DIRexportSECURITY.mdLayer 23 claimed a protection that no code provided. It asserted that
ask-user context files "are scoped per user to prevent cross-user race conditions" for a
version in which nothing read the per-user file, describing the intent as though it were
the behaviour. It now describes both halves of the handshake and names the residual: a bot
paired with an older subprocess still falls back to the shared file. Recorded in
docs/security-limitations.mdas limitation 8, kept in the list as closed rather than
deleted, so a reader who met it as an accepted risk can see where it wentsrc/security.tsstill hedged about something that has since been measured. It carried
the pre-measurement note thattools"is not shown to constrain MCP tools". It is now
measured and stated as fact, with the reach of both lists spelled out, matching the table
added toREADME.md- The
ASK_USER_DIRcomment insrc/config.tsundercounted the places it replaced. It
said the path had been hardcoded in "three places"; it was four, as the changelog entry
above already enumerated. Three undersrc/plus the MCP subprocess AGENTS.mdwas missing one of the paths its own section claims are derived from
BOT_ID. It lists~/.<BOT_ID>/ask-user/among the runtime paths; that one was absent- Removed
getTextFromMessage()fromsrc/session.ts. It was defined and called from
nowhere, and its removal took the orphanedSDKMessageimport with it. Without this the
file would have crossed the 800-line limit inCONTRIBUTING.mdat 801 - A documented guarantee about tool-name drift is now enforced instead of asserted.
ASK_USER_SERVER_KEYdocumented itself as the source themcp__ask-user__*tool names are
derived from, "so the two cannot drift apart", whilesession.tsmatched on the string
literal and could drift apart freely. The prefix is now derived from the key
(ASK_USER_TOOL_PREFIX) and the literals are gone, which makes the existing claim true
instead of softening it. Same resolved value, no behaviour change
Changed
- The CI configuration-documentation gate now also covers the MCP subprocess. It scanned
onlysrc/, so the subprocess readingBOT_ID, introduced by this same release, would have
gone undocumented without anything noticing. It now scansask_user_mcp/*.ts.
ASK_USER_USER_IDis exempt from the gate: the bot injects it per query, and documenting
it in.env.examplewould invite the manual override that breaks the handshake - An allowlist alone does not make the bot read-only once an MCP server is configured.
Tool control documentation corrected after measuring against SDK 0.3.233:ALLOWED_TOOLS
(tools) governs built-in tools only. WithALLOWED_TOOLS=Read,Grep,Globthe built-ins
reduce to those three while every tool of every configured MCP server remains available.
Both lists are needed DISALLOWED_TOOLSdoes reach MCP tools, in both of its forms. The earlier caveat that
the wildcard and server-level forms might be honoured only by the bot's own runtime check
was too cautious. Bothmcp__serverandmcp__server__*remove the server's entire tool
set from the session itself
v1.3.0: Tool restrictions, a spending ceiling and a quiet mode
Added
- The bot can be restricted to a set of tools instead of only a set of paths.
ALLOWED_TOOLSandDISALLOWED_TOOLS(both empty by default). Until now the bot passed no
tool restriction to the Agent SDK at all while running with
permissionMode: "bypassPermissions":ALLOWED_PATHSbounded where Claude could work,
nothing bounded what it could do there, so a fork pointed at a documentation repository had
no way to make it read-only. The lists are handed to the SDK astoolsand
disallowedTools. The similarly namedallowedToolsoption is deliberately not used: it
is an auto-approve list for permission prompts and enforces nothing under
bypassPermissions. Leaving both unset keeps the previous behaviour exactly - A monthly spending ceiling per user, surviving restarts.
COST_LIMIT_USD(unset by
default = unlimited) tracks cumulative spend per Telegram user and calendar month,
persisted in the existing SQLite database (ALERT_DB_PATH, newuser_coststable).
sessions.jsonwas the other candidate and lost on one point:loadSessionHistory()
swallows parse errors and returns an empty history, so a corrupted file would have reset
the budget to zero without saying so. The period (YYYY-MM, local time) is part of the
primary key, so the monthly reset needs no scheduled job that could fail to run. Exceeding
the limit produces an explicit message naming the amount, the ceiling and the reset, not a
bare refusal - The stream of tool calls and reasoning fragments can be turned down or off.
VERBOSE_LEVEL(default1= previous behaviour) and a/verbosecommand. The bot
streamed every tool call and every reasoning fragment unconditionally, which is the point
for system administration and a wall of noise for anything else.0shows the final answer
only,1keeps thinking previews and tool status,2adds tool results and a closing line
with duration, tokens and cost. The per-user override is held in memory on the session
exactly like the/modeloverride: it survives/new, is lost on restart, and then falls
back to the environment default /statusshows what the month has cost so far. With the ceiling and percentage when
one is configured, and shown regardless of whether a limit is set, because picking a
sensible ceiling requires seeing the number first
Changed
- A refused request now says why it was refused. Blocked tool calls, blocked commands and
denied paths are reported through a newnoticestatus type instead of the ephemeral
tooltype. Refusals were pushed onto the same message list that is deleted when a turn
ends, and deleted again by the error handlers intext.ts/media-group.ts, so the user saw
the generic error and not the reason. On the alert path they were not delivered at all,
becausecreateAlertStatusCallbackimplementstool/segment_end/doneand nothing else - Those refusal messages appear at every verbose level, including
0. They look like
status updates but are the only explanation a user gets for a refused request; gating them
would have turned a noise setting into a security setting and quietly undone the tool
control above /alertbills the person who typed it, not the alert session. The alert session
(synthetic user ID 0) is exempt from the cost ceiling on purpose, because a budget must
never be able to silence system monitoring, and without this attribution a user who had hit
their limit could have kept working through/alerton that exemption- Tool-call safety checks moved out of
session.tsintocheckToolCallSafety()in
security.ts. Adding three features tosendMessageStreaming()had pushedsession.ts
past the 800-line limit inCONTRIBUTING.md; the checks are questions about safety and
belong in that module. No behaviour change README.mdis titled "Claude Code Telegram Bot" instead of the repository slug.
cc-telegram-botsaid what the directory is called rather than what the project does
Fixed
- The size claim in
README.mdandAGENTS.mdwas almost a thousand lines short. Both
claimed "~5,900 lines TypeScript". Measured acrosssrc/andask_user_mcp/: 6,838. The
figure had been drifting since before v1.2.0
Security
- Two documents told operators that tool restriction was not available.
docs/security-limitations.mdandSECURITY.mddocumented restricting tool usage as "not
implemented" and as something to do "via the system prompt". Both are now env-configurable
and SDK-enforced - A documented claim about sub-agent tool calls was removed rather than carried forward.
docs/security-limitations.mdclaimed "there is no SDK-level hook to intercept sub-agent
tool calls". That is not true of Agent SDK 0.3.233: aPreToolUsehook fires before
execution and inside sub-agents (HookInput.agent_idexists to tell them apart), and it is
what the SDK recommends for gating underbypassPermissions, whilecanUseToolis
auto-approved past and never consulted. No hook is installed here; that reworks the
permission path and belongs in its own change. What the bot's runtime check does and does
not do is now stated precisely: it detects after dispatch, sub-agent calls included, and
does not prevent - The holes in cost accounting are stated plainly rather than left to be discovered.
Automatic socket alerts are exempt from the ceiling by design (see above);total_cost_usd
is only emitted on the result event, so the four paths that leave the stream earlier (the
inline-keyboard hand-off,/stop, a!interrupt, and a tool call blocked by the checks)
consume real tokens and book zero; and because the ceiling is checked before a turn rather
than during it, a single turn can exceed it by any amount. The SDK'smaxBudgetUsdwould
close the latter two, but needs its own handling of theerror_max_budget_usdresult
subtype and is therefore left to a separate change - Adding a column to
user_costslater will need a migration that does not exist yet.
The table is created withCREATE TABLE IF NOT EXISTSand there is no migration mechanism,
matching the existingalertstable. Reverting leaves the table in place, which is
harmless, because older code never reads it - Tool control is intentionally not counted as a 25th security layer. The 24 numbered
layers describe what protects an operator who changed no configuration, and these lists are
empty until someone fills them in. A future security audit may reasonably take tool control
up as Layer 25 once it has been reviewed as one;docs/SECURITY_AUDIT_REPORT.mdis a dated
document and has been left untouched
v1.2.3: Two bots on one machine keep their own state
Added
- Every per-instance path derives from one setting.
BOT_ID(default
cc-telegram-bot) governs~/<.BOT_ID>/(sessions, audit log, alert database),
/tmp/<BOT_ID>/(downloaded media) and/run/<BOT_ID>/alerts.sock. Validated as a single
path segment at startup, so a value like../../etcaborts instead of escaping into the
filesystem - The bot's role description can be changed without editing the source.
BOT_PERSONA
(default unchanged) sets the one-line role description at the top of the system prompt; the
detailed personality stays inCLAUDE.mdinsideCLAUDE_WORKING_DIR
Fixed
- Two instances of this bot on one machine shared their state without saying so. The data
directory (~/.cc-telegram-bot) and temp directory (/tmp/telegram-bot) were hardcoded, so
both wrote the samesessions.json: last write won, and a conversation could surface in the
wrong bot. Set a distinctBOT_IDper instance - A bot without alerting no longer carries the alert half of the safety prompt. The alert
rules (7 through 12, roughly half the safety prompt) were emitted unconditionally. They now
follow the same gate as the runtime, since the alert socket only starts when a group is
whitelisted (src/index.ts), so the rules only appear whenTELEGRAM_ALLOWED_GROUPSis
set. Without alerts the prompt drops from 4097 to 2181 characters, and the bot no longer
carries instructions to scanDOCS_DIRand await/alertapproval for a subsystem that
never runs - A bot built on this code for another purpose was told it was an admin bot. The persona
line was hardcoded as "You are a Claude-powered Telegram admin bot", which outranked the
bot's ownCLAUDE.md, because the system prompt wins
Changed
- Dependencies raised: Claude Agent SDK 0.3.215 -> 0.3.233, MCP SDK 1.29.0 -> 1.30.0,
TypeScript^5->^5.9.3
v1.2.2: The EOD report script is executable
Fixed
scripts/trigger-eod-report.shcould not be run directly. It shipped without the
executable bit since v1.0.0, so./scripts/trigger-eod-report.shfailed with "Permission
denied"; it worked only when invoked asbash scripts/trigger-eod-report.sh
v1.2.1: Continuous integration, and the last two undocumented settings
Added
- Every push and pull request is now checked by CI (
.github/workflows/lint.yml). The
repository had no CI at all until now, so every check ran locally or not at all. The
workflow runs typecheck (bun install --frozen-lockfile+bun run typecheck), ShellCheck
on the two shell scripts, an XML parse of the LaunchAgent template, three
configuration-documentation gates, a relative-link check across all Markdown, a changelog
link-definition check, and gitleaks over the full history CLAUDE_CODE_PATHis documented in.env.exampleandREADME.md. It sets the Claude
Code executable handed to the Agent SDK (src/session.ts) and is needed for standalone
buildsDOCS_DIRis documented in.env.exampleandREADME.md. Claude scans this directory
for existing documentation before diagnosing an alert (src/config.ts); default
~/Documents/docs
Fixed
CLAUDE_CODE_PATHandDOCS_DIRwere findable only by reading the source. Both were
configurable in code and appeared in no documentation- Four documented settings were missing from the README configuration table.
CLAUDE_CLI_PATH,WHISPER_CLI_PATH,AUDIT_LOG_MAX_SIZEandAUDIT_LOG_MAX_FILESwere
documented in.env.examplebut not in the table - The
[Unreleased]compare link pointed one release too far back. The[1.2.0]compare
link was never added in the 1.2.0 release, which left[Unreleased]comparing against
v1.1.0. Both corrected, and the check is now part of CI
Changed
.gitignorenow covers local agent tooling and further private note formats.
.claude/,REDDIT_POST.md,TODO.md,NOTES.md.CLAUDE.mdstays versioned on
purpose: it is the shipped example system prompt, documented inREADME.mdand
CONTRIBUTING.md