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.
- A prompt with no new sender keeps the turn's rules. Claude Code also reports prompts that no Telegram message accounts for while a Telegram turn is running: a message typed in the terminal and queued behind the turn (reported the moment it is queued), a message from another local Claude session, or the same Telegram message delivered again after a compact. Such a prompt adds no Telegram sender, so the turn keeps the rules it was running under, made stricter by any message still waiting to be picked up and never looser. A prompt that carries Telegram text aipager cannot attribute still falls to the built-in floor, and a message from a less-privileged sender still lowers the turn to that sender's rules.
What actually contains a restricted user. The Claude session runs
as the same OS user that owns aipager's config (including the bot token
in ~/.config/aipager/daemon.env) and its per-turn policy file
(/tmp/claude-policy-<session>.json). A shell running as that user can
reach both through spellings no pattern anticipates
(sed -i … /tmp/*-policy-*.json, a for loop over a glob, a Python
one-liner) and rewrite its own rules to the owner's. Command patterns
are therefore a filter, not a boundary. For a turn run under the
built-in restricted roles (user, read_only), and for a turn aipager
cannot attribute, aipager enforces these rules, none of which depend on
command patterns (see the known limits below for what they do not
cover):
- No shell. They cannot use
Bash, or any other tool Claude Code runs code with (PowerShell,Monitor,REPL,Workflow, the prompt schedulersCronCreateandRemoteTrigger, …; the list issafety.CODE_EXECUTION_TOOLS). A scheduled prompt would arrive without the Telegram marker, i.e. unrestricted, so those count too. For the same reason they cannot useSendMessage(it can hand a prompt to another local Claude session, which would run it as a terminal prompt), norEnterWorktree/ExitWorktree(they move the session's working directory, which the next rule relies on). - Writes stay in the project.
Write,Edit,MultiEditandNotebookEditland only inside the session's folder (the working directory Claude Code reports in the hook payload, never a path from the tool call) and the session's Claude Code scratchpad (/tmp/claude-<uid>/<project>/<session id>/, or/tmp/claude-<uid>/when that cannot be worked out). Everything else is denied: your shell startup files,~/.ssh, other projects, aipager's/tmpfiles. Inside the folder,.claude/,.git/and.mcp.jsonare denied too, because each of them makes Claude Code or git run a command. Symlinks are followed before deciding. - Searches stay in the project. A
GreporGlobis allowed only when all of this holds: the folder it searches (itspath, or the session's folder when it has none, symlinks followed) is inside the session's folder or scratchpad; that folder is not a protected path and holds none (a session started in your home folder holds~/.config/aipager, so it cannot search from its top); every glob is a plain relative pattern — no leading/,~,$or drive letter, no.., no#(a comment to ripgrep) or!(a negation), no\escape; and you have no protected-path rule without a leading/or~(**/.envcan sit anywhere in the project, so with one in place every restricted search is denied). Anything else is denied. This is an allow-list on purpose: three review rounds each found a new glob spelling that led ripgrep out of the folder a model of its parsing expected, and one of them read the bot token. - The file tools honour the protected paths.
Read,LSPand the write tools check where a path really leads, symlinks included. The one control file a turn may read is its own session's/tmp/claude-reply-<session>.txt, which aipager names in the prompt when you reply to an older message; other sessions' reply files, and any write to it, stay denied. On macOS,/tmpis/private/tmpand the disk ignores case, so both spellings, and any capitalisation, are matched. - A check that fails denies. If deciding on a tool call raises an error (a NUL byte in a path, a bug), or the hook runs out of memory while deciding, the call is denied — unless the session's rules could be read and grant the owner's bypass. It used to be let through.
Turns aipager cannot attribute. A Telegram turn with no sender to
hold it to runs under the built-in floor, which matches the built-in
user role (not any extra restrictions you add to user in
policy.yaml): no code-running tools, writes and searches confined, the
protected paths. That happens for a message whose permission note
expired (after 24 hours) or could not be written, and for a prompt
carrying Telegram text that no note matches. The Retry button runs as
whoever tapped it when they also sent the prompt being retried, so an
owner's Retry of their own message keeps Bash; a Retry of someone
else's message, or of one whose sender aipager does not know, runs on
the floor, so nobody's text borrows the tapper's rights.
Reads outside the protected paths are allowed: a restricted user can
read other projects of the OS user. Owners are not held to any of this.
Admins' writes are not confined, and their Grep/Glob are checked on
the folder they start in only — they have Bash, so their rules are
best-effort anyway (below).
Known limits. These are not covered:
- If the hook itself fails before it starts deciding (for example it cannot read its own status file, or runs out of memory while starting), Claude Code treats that as "allow". A restricted user cannot cause this; any error while deciding denies.
- Tools that are not file tools but can read a file or send data out
are not path-checked:
SendFile,WebFetch(anything the turn can read it can send to a URL), and Claude Code's other file-delivery tools. Deny them for a role inpolicy.yamlif that matters to you. - A background agent a restricted turn started keeps running after the turn ends, and its later tool calls are judged by whichever prompt is newest in the session: a later owner or terminal prompt lifts its restrictions.
- A session started in your home folder confines writes to your home folder: a restricted user there can write your shell startup files. Start restricted users' sessions in a project folder.
- Inside the project a restricted user can change what you later run
or read yourself (source,
Makefile,package.jsonscripts,conftest.py, aCLAUDE.mdyour own turns load).
A role that has Bash — admin, which bypasses role deny rules, or
any role policy.yaml gives it back to (allow_tools naming Bash,
or a replaced deny_tools) — is held to best-effort rules only:
the command patterns below, which a determined user can get around.
aipager doctor warns once for every such role a member holds. Only
give Bash to someone you would give a shell on the machine.
The built-in command rules. Among the Bash patterns every
non-owner Telegram turn with Bash is held to, one blocks the word claude: it
stops a nested claude (with any path, wrapper or flag) and any
~/.claude access in one rule, and it also catches the plain names of
aipager's own /tmp/claude-* control files, whose names share the
prefix (a pattern is not a real protection for those files: a glob
spelling avoids it). The one exception is the scratchpad folder Claude
Code creates, /tmp/claude-<uid>/…: that exact directory, as a whole
path component, is exempt, so commands run from a session's scratchpad
are not halted. It belongs to the OS user, not to one session, so a
turn with Bash can read other sessions' scratchpads and task output
there, as the Read tool can. A glob or look-alike
of it (/tmp/claude-1*, claude-1000x), another temp directory, and
anything reached from it (…/../../home/you/.claude) are still
blocked. Other mentions of the word (git log --grep=claude, a file
named claude-notes.md, pip show claude-agent-sdk) are also still
blocked, deliberately: the same word names the binary, its SDKs and
aipager's control files, and a pattern cannot tell them apart. Rules
you add in policy.yaml see the command exactly as typed, scratchpad
included.
Why one block halts the whole turn. After a tool call is blocked, every later call in the same turn is denied too, until the next prompt. Without this, the agent simply retries the same action reworded (a glob, a different command, an encoded path) until one spelling slips past the patterns. Halting the turn makes a first attempt costly; it does not make a pattern a boundary, since the first spelling tried may be the one that slips through. The cost of a false positive is one stopped turn, which is why the rules above are kept narrow.
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). - HTTPS to
telegram.orgfor the Mini App's SDK script (telegram-web-app.js), fetched once when the Mini App server starts and refreshed at most once a day; cached under~/.local/share/aipager/webapp-sdk/. The daemon serves those bytes to the page from its own origin and never executes them. aipager does not redistribute the script: nothing is bundled in the package, and when the daemon has no copy the page loads it fromtelegram.orgdirectly, as it did before. - 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 |
| Restricted Telegram user escalates to the owner | No shell, writes and searches confined to the project, protected paths for the file tools, failed checks deny (team-mode enforcement); best-effort only for a role given Bash, and not covered for the cases under "Known limits" |
| 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.