aipager runs Claude Code on your behalf and is driven from Telegram. Two questions follow:
- Who can drive the bot?
- What can the bot do once driven?
Every handler the bot exposes — message, file, voice, callback —
is gated by python-telegram-bot's chat filter, built from the
chat(s) configured in ~/.config/aipager/aipager.yaml. Only the
configured chat(s) can interact with the bot. Messages from any
other chat are silently ignored at the framework layer. In team
mode a per-user allow-list with roles is layered on top — see
groups.
This means the surface to "outside the world" is:
- The bot token (a secret).
- The chat ID (a long integer).
If both leak, an attacker can drive your daemon. If only the token leaks, an attacker can read DMs sent to the bot from your chat but cannot send commands the daemon will act on. If only the chat ID leaks, nothing useful — the bot needs the token to talk to Telegram at all.
| Secret | Location | Mode |
|---|---|---|
| Bot token | ~/.config/aipager/aipager.yaml |
600 by default |
| Chat ID(s) | same file | 600 by default |
Neither value is ever logged — error messages and the wizard redact
the token wherever it could appear. Neither is committed — the
config lives in the user's ~/.config, not the repo. The Trusted Publisher
PyPI release flow never touches secrets either; OIDC handles auth.
If you suspect the token is compromised, revoke it from
@BotFather (/revoke), generate a new
one, and re-run aipager config.
aipager runs an autonomous agent with Bash access as your UNIX user. Therefore any secret aipager can read, the agent it launches can read too. This is worth stating plainly, because it is easy to reach for the wrong kind of control.
~/.config/aipager/ is on safety.py's deny-list, which stops a
Telegram-driven session from reading files under it. That is a
path control, and it does nothing here: the Claude credential
(CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY) is not reached by
path — it is inherited into every spawned session's own process
environment. Inside a session, echo $CLAUDE_CODE_OAUTH_TOKEN
prints it, deny-list or not. A file-path rule is the wrong shape of
control for an environment variable.
| Deployment | Source | Mode |
|---|---|---|
| systemd-user service | LoadCredential=claude_oauth:%h/.config/aipager/daemon.env, read from $CREDENTIALS_DIRECTORY/claude_oauth |
600 (daemon.env); systemd keeps the credential out of the unit's own environ |
| macOS / Docker / no systemd | ~/.config/aipager/daemon.env, read directly |
600 |
Both paths are parsed by the same code (aipager/daemon_secrets.py)
and handed to the launched session via the subprocess env= table —
never interpolated into the bash -c command string. /proc/PID/cmdline
is world-readable (0444); /proc/PID/environ is 0400 (owner-only).
What LoadCredential= + 0600 genuinely buys: other UNIX users on the
same machine cannot read the credential, it never appears in
systemctl show, in a unit-file backup, or in a screenshot of
journalctl output an operator pastes into a bug report. It does
not create a boundary between aipager and the agent aipager
launches — that boundary does not exist, and no amount of file-
permission engineering can create it, because the agent's whole job
is to act as the operator inside that same environment.
Scope the credential itself. Run the daemon on a separate
Anthropic account, or with an API key that carries a spend limit —
not the same token you use for your own interactive claude login.
Revoking the daemon's credential must never log the human out; if it
would, the two are the same credential and neither is scoped.
aipager doctor --fix can discover an existing token to seed
daemon.env, but choosing which token to hand it is the operator's
call, and it is the one decision in this document that matters more
than the file permissions around it.
aipager doctor (and the one-line notice sent once per daemon start)
report auth via Claude Code's own claude auth status — never a
hand-rolled file check. Three states are kept textually distinct
everywhere they appear:
auth: <method> (<source>)— confirmed logged in.auth: none (not logged in)— confirmed not logged in.auth: unknown (...)— the probe itself failed (timeout, missing binary, unparseable output) or the binary predates the version that added JSON output. This is never reported as "not logged in": aipager does not refuse to launch a session on an auth check it isn't sure about. macOS Keychain-based auth, refresh-token-only credentials, and Max-plan non-file auth are all invisible to any file check and would otherwise look identical to "logged out".
aipager is not the permission gate for tool calls. Claude Code's
~/.claude/settings.json is. The flow:
- Claude wants to run
Bash: rm -rf /. - Claude consults its settings → matches a rule that says
Ask. - Claude fires
PreToolUse(see hooks). - aipager relays the prompt to Telegram and waits for your tap.
- You tap
[✅ Allow]or[❌ Deny]. - aipager writes
approveordenyback to claude via the hook protocol. - Claude honours the decision.
If your settings.json says Deny for that tool + input combo,
the prompt never even reaches Telegram — claude blocks the call
itself. aipager only sees Ask cases.
This matters because: aipager cannot expand claude's permissions.
It can only relay prompts claude code chose to surface. If you want
to lock down further (e.g. forbid Bash: rm), edit
~/.claude/settings.json; aipager will respect it.
In team mode aipager adds its own layer underneath the taps: every
Telegram-originated message carries a permission note — who sent it
and what their role allows — written to
/tmp/claude-notes-<session>/, one file per message. When Claude
picks a prompt up, the aipager-hook helper matches it against those
notes and installs the sender's rules where the PreToolUse check
reads them. Two properties are load-bearing:
- Strictest wins. If Claude picks up messages from more than one contributor as a single turn (aipager holds messages to prevent this, but fails safe if it happens anyway), the turn runs under the most restrictive combination — an owner's message can never lend its privileges to someone else's.
- Unknown means restricted. A prompt that cannot be attributed runs under the built-in floor — no bypass, all deny rules active — never as an unrestricted terminal prompt. Prompts you type directly into the terminal remain unrestricted; anything carrying the Telegram marker on any line is enforced.
See groups → how rules work for the user-facing rules these notes carry.
Every Allow / Deny tap, plus every AskUserQuestion
answer, appends one JSON line to ~/.claude/aipager-audit.jsonl:
{
"ts": "2026-05-18T15:42:11+00:00",
"session": "claude-jim",
"label": "jim",
"action": "Allowed",
"tool": "Bash",
"summary": "ls -la /tmp",
"user_id": 12345,
"username": "alice",
"denied": false
}Fields (see aipager/audit.py for the full set):
ts— ISO 8601 UTC timestamp, second precision.session/label— internal name and friendly label.action— what happened (Allowed,Denied,Allowed always, anAskUserQuestionanswer, an auto-deny, …).tool/summary— the tool name and a truncated input or question body.user_id/username— who tapped, for team-mode attribution;scope_label/scope_chat_idwhere multiple chats are configured.denied— true for any refusal, tap-driven or rule-driven.
Write is best-effort. If the disk fills up or ~/.claude/ becomes
unwritable, the daemon logs a WARNING and keeps running — no
silent loss, no crash. See aipager/audit.py.
The audit log is append-only on disk. Pair it with the in-chat audit reply (one Telegram message per decision, threaded under the busy message) for two independent records.
The daemon never elevates. No sudo, no setuid, no doas. Every
file written lives under $HOME. Every subprocess
(claude, dtach, pip installs, npm) runs as the daemon user.
The Telegram-driven extra-install flow (e.g. tapping [📦 Install voice]) explicitly uses sys.executable -m pip install, which
writes into the daemon's own venv — never the system Python.
The Telegram-driven daemon-restart flow ([🔄 Restart daemon now])
spawns a detached child with start_new_session=True, then SIGTERMs
the current process. Both processes run as the same user; no
escalation.
aipager listens on no non-loopback TCP port. The Mini App server
binds 127.0.0.1 only (hardcoded — there is deliberately no host
option) and is reachable from outside solely through the managed
tunnel described below. Outbound:
- HTTPS long-poll to
api.telegram.org(Telegram bot polling). - The Mini App tunnel to Cloudflare, while enabled (the default).
- HTTPS to
pypi.organd friends, only when the user taps the voice install button.
Inbound:
- Unix datagram socket at
$XDG_RUNTIME_DIR/aipager.sock(falling back to/tmp/aipager.sockwhen$XDG_RUNTIME_DIRis unset). Bound and chmod'd by the daemon at startup (aipager/dtach/hook_receiver.py). Mode0o666so any local process can send hook events to it — same trust as~/.claude/settings.json, which already controls what runs hooks.
This means: the daemon is not a remote attack surface. A network- level attacker cannot reach it without a foothold on the host.
The Mini App is on by default, so on a stock install aipager opens
this tunnel without being asked. That is a deliberate trade — an opt-in
nobody discovered was a feature that did not exist — but it means a
fresh install reaches the internet. Turn it off with aipager miniapp disable, or set enabled: false under miniapp: in aipager.yaml;
an explicit false is always respected. Note that a missing or
corrupt miniapp: block now falls back to ON rather than OFF.
The Mini App (unless a --url override is set) spawns a managed
Cloudflare quick
tunnel
pointed at the daemon's own loopback server, and publishes whatever
public https://*.trycloudflare.com address it is assigned as the
Telegram menu button, the reply-keyboard button, and /app's answer.
That address is not secret, and is not meant to be: every request
the Mini App serves is verified against Telegram's initData signature
(see Trust boundary above), the same check that
protects it regardless of how the URL was obtained. Knowing the
hostname alone gets an attacker nothing they could not already get by
guessing a trycloudflare.com subdomain at random — the signature
check is the actual gate. Treat it the way you'd treat any other
unlisted-but-not-secret URL: fine to leave running, not something to
paste into a public channel for no reason.
The hostname changes on every daemon restart and is held only in
memory — it is never written to aipager.yaml or anywhere under
~/.config/aipager/. If the tunnel dies mid-run,
aipager restarts it with backoff and republishes the new address; if
it cannot come back up after several attempts, the button is removed
rather than left pointing at a dead port (see Network
surface above for the same "no button is an honest
absence" principle applied to the loopback server itself).
Setting MINIAPP_PUBLIC_URL (or aipager miniapp enable --url https://…) disables the managed tunnel entirely — no cloudflared
binary is ever fetched or spawned — and the given URL is used as-is,
with the same initData verification underneath it. Tailscale
auto-detect (tailscale status --json) remains available as a
lower-effort alternative for anyone who already runs Tailscale and
would rather not depend on Cloudflare at all.
faster-whisper runs in-process. The audio is downloaded as .ogg
into ~/.config/aipager/files/, transcribed locally on CPU, and
the file stays under your control. No audio leaves the machine.
No third-party API. No key needed beyond the bot token to talk to
Telegram in the first place.
If you delete the .ogg after transcription, the only record of
the message in plain text is the transcript that gets injected into
the claude session (where it follows claude code's own privacy
posture).
Each Claude Code session runs in its own dtach. The control socket
at /tmp/claude-dtach-<name>.sock is owned by the daemon user;
dtach refuses cross-user attaches. Inside the session, claude code
operates with whatever --cwd it was launched in.
aipager does not implement filesystem-level isolation between
sessions: a session attached to ~/projects/foo can in principle
read ~/projects/bar if claude code's permissions allow. Use
per-project ~/.claude/settings.json overrides or a container
(Docker image) for stronger isolation.
| Threat | Mitigation |
|---|---|
| Stranger sends bot a command | Chat ID filter rejects |
| Stolen bot token | Use /revoke in @BotFather, re-config |
| Compromised claude tool call | Claude's settings.json is the gate; aipager respects it |
| Audit log tampering | Append-only; out of scope to prevent without a separate signing daemon |
| Network attacker | No inbound port, not directly reachable |
| Local privilege escalation | No sudo / setuid; daemon stays in user space |
| Voice audio leaking to cloud | Transcription is local |
| Agent reads the daemon's own Claude credential | Expected, not a bug — see The Claude credential. Scope the credential itself, not the file it's stored in |
- Architecture — process model.
- Hook events — what the daemon actually sees from claude.
- Bot commands — the user-driven side.