安全模型
aipager 代表你运行 Claude Code,并从 Telegram 控制。这带来两个问题:
- 谁能控制机器人?
- 被控制时,机器人能做什么?
信任边界#
机器人注册的每个处理程序(消息、文件、语音消息、按钮)都经过 python-telegram-bot 的聊天过滤器,这个过滤器由 ~/.config/aipager/aipager.yaml 里配置的聊天生成;命令则经过 aipager 自己授权环节里的同一项检查。唯一的例外只会记录未配置群组里的发送者(见下文)。只有配置过的聊天才能与机器人交互。 来自任何其他聊天的内容都不会被处理,也不会被转给会话。未配置的私聊只会收到一条回复:“这个机器人还没有设置为与你对话”。在未配置的群组里,机器人从不回复,但会把在那里给它发消息的人(这个人的 Telegram ID、用户名和名字,以及群组的 ID、标题和类型)记录到 ~/.claude/aipager-pending-users.json,这样 aipager config 就能在 aipager 运行时找到新群组和其中的人。任何人都可以把机器人加入群组,所以任何人都可以往这个文件里添加一行;这不会给他们任何权限。这个文件只有你能读(权限 0600),每次写入都会整体替换(绝不会只写一半),最多保留最近的 200 人,并删除 30 天内没有出现过的人。在团队模式下,上面还有一份带角色的允许名单,并且在群组里只处理发给机器人的消息(一个命令、对机器人消息的回复、提及机器人的消息,或者在机器人键盘上的一次点按),无论机器人是不是群组的管理员(见群组)。
所以,向外部世界开放的只有:
- 机器人的 token(一个机密)。
- 聊天 ID(一个很长的整数)。
如果两者都泄露了,攻击者就能控制你的 aipager。如果只泄露了 token,攻击者能读到从你的聊天发给机器人的消息,但无法发送任何 aipager 会执行的命令。如果只泄露了聊天 ID,没什么大不了:机器人需要 token 才能与 Telegram 通信。
机密信息#
| 机密 | 位置 | 权限 |
|---|---|---|
| 机器人 token | ~/.config/aipager/aipager.yaml | 默认 600 |
| 聊天 ID | 同一个文件 | 默认 600 |
token 不会出现在 aipager 的日志里:带有 token 的 HTTP 请求行不会被记录,错误消息、aipager doctor 和设置向导都会遮盖任何看起来像机器人 token 的内容。聊天 ID 会出现在 aipager 的日志里(例如在限流相关的行里),所以请把 aipager logs 的输出当作私人内容。两者都不会被提交到代码仓库:配置在用户的 ~/.config 里,不在仓库里。通过 Trusted Publisher 发布到 PyPI 的流程也从不接触机密;认证由 OIDC 负责。
如果你怀疑 token 泄露了,到 @BotFather 撤销它(/revoke),创建新的 token,然后重新运行 aipager config。
Claude 的登录信息:真正保护它的是什么#
aipager 运行的是一个能以你的 UNIX 用户身份使用 Bash 的自主代理。所以 aipager 能读到的任何机密,它启动的代理也能读到。 这一点值得直说,因为很容易依赖错误类型的保护。
~/.config/aipager/ 在 safety.py 的禁止列表里,这个列表阻止从 Telegram 控制的会话读取其中的文件。这是一种基于路径的保护,而在这里它没有作用:Claude 的登录信息(CLAUDE_CODE_OAUTH_TOKEN 或 ANTHROPIC_API_KEY)不是通过路径取得的,而是传入每个启动的会话自己的进程环境。在会话里,无论有没有禁止列表,echo $CLAUDE_CODE_OAUTH_TOKEN 都会把它打印出来。基于文件路径的规则,用来保护环境变量是错误的工具。
登录信息存放在哪里#
| 部署方式 | 来源 | 权限 |
|---|---|---|
| systemd 用户服务 | LoadCredential=claude_oauth:%h/.config/aipager/daemon.env,从 $CREDENTIALS_DIRECTORY/claude_oauth 读取 | 600(daemon.env);systemd 把登录信息放在服务自身的环境之外 |
| macOS / Docker / 没有 systemd | ~/.config/aipager/daemon.env,直接读取 | 600 |
两条路径由同一段代码读取(aipager/daemon_secrets.py),并通过子进程的 env= 表交给启动的会话,从不插入 bash -c 的命令字符串里。/proc/PID/cmdline 任何人都能读(0444);/proc/PID/environ 是 0400(只有所有者能读)。
LoadCredential= 加 0600 真正带来的是:同一台电脑上的其他 UNIX 用户读不到登录信息,它也绝不会出现在 systemctl show、服务文件的备份,或者机器人主人粘贴到 bug 报告里的 journalctl 截图中。它不会在 aipager 和 aipager 启动的代理之间建立任何边界:那条边界不存在,任何文件权限的调整都建立不了它,因为代理的全部工作就是在同一个环境里以机器人主人的身份行事。
唯一真正有效的措施#
限制登录信息本身。 让 aipager 使用一个单独的 Anthropic 账号,或者一个有消费上限的 API 密钥,而不是你自己交互式登录 claude 时用的那个 token。撤销 aipager 的登录信息绝不应该让你自己也被登出;如果会,说明两者是同一份登录信息,哪一个都没有受到限制。aipager doctor --fix 可以找到一个现成的 token 填进 daemon.env,但把哪个 token 交给它是机器人主人的决定,而这是本文档里唯一比周围文件的权限更重要的决定。
不靠猜测地诊断登录问题#
aipager doctor(以及 aipager 每次启动时发送一次的单行通知)通过 Claude Code 自己的 claude auth status 报告登录状态,从不靠自制的文件检查。三种状态在出现的每个地方都写得不一样:
auth: <method> (<source>):确认已登录。auth: none (not logged in):确认未登录。auth: unknown (...):检查本身失败了(超时、找不到程序、输出无法读取),或者程序比加入 JSON 输出的版本更旧。这种情况绝不会被报告为“未登录”:aipager 不会因为一项它没有把握的登录检查就拒绝启动会话。通过 macOS 钥匙串登录、只有 refresh token 的登录信息,以及不使用文件的 Max 订阅登录,对任何文件检查来说都是看不见的,否则就会看起来和“已登出”一模一样。
谁决定一次工具调用能否运行#
有两项独立的检查决定一次工具调用能否运行,它们按这个顺序进行:
- aipager 的安全检查。 Claude Code 在每次工具调用之前运行 aipager 的
PreToolUsehook。对于从 Telegram 开始的任务,hook 会让调用遵守发送者角色的规则和安全底线(受保护路径、命令模式),并拒绝它们不允许的东西:这时 Claude Code 会拒绝这个工具,不问任何人,无论它自己的规则怎么说。在终端里输入的消息不受这项检查的限制。它包括下面团队模式下的规则执行里描述的内容。 - Claude Code 自己的规则(
~/.claude/settings.json、会话的权限模式)。如果它们说要询问,Claude Code 就会发出PermissionRequest,aipager 会在 Telegram 里显示权限请求(见 hook)。你的点按会作为 hook 的决定回到 Claude Code,或者如果 20 秒的等待已经过去,就输入到 Claude Code 自己的对话框里。如果 Claude Code 的规则拒绝了这次调用,它就绝不会到达 Telegram。
所以 aipager 可以拒绝一次 Claude Code 本会允许的调用,而当 Claude Code 询问时,aipager 绝不会自己回答“是”:允许总是来自一个人的点按。有三样东西能让调用不经询问就运行:
- 完全访问模式,给管理员(以及在自己私聊里的人)。 完全访问模式的会话以
--dangerously-skip-permissions启动:Claude Code 什么都不问,所以 aipager 安全检查没有拒绝的每次调用都会不经点按直接运行。只有管理员(owner和admin角色,或者有can_manage的角色)才能以完全访问模式启动会话、把会话切换到这个模式(/mode full,需要确认)或在这个模式下恢复会话,只有一个例外:在自己与机器人的私聊里,角色可以发送消息并回答 Claude 的人(can_prompt和can_approve,也就是user,但不是read_only)可以对自己的会话做同样的事(/new !x1,或者在那个聊天里把“新会话”设为完全访问模式、/mode full、/resume),在 Telegram 和 Mini App 里都可以。绝不在群组里,也绝不在别人的私聊里。其他所有人会改为得到自动模式,回复里会说明这一点。完全访问模式只跳过 Claude Code 自己的询问:aipager 的 PreToolUse hook 仍然会对每次调用执行这个人的角色规则(安全底线、文件夹限制、禁止规则、存放登录信息的文件),和在询问模式、自动模式下完全一样;Claude Code 报告的模式绝不会放宽它们。在完全访问模式下,aipager 的安全检查仍会在每次调用时运行,但对所有者自己的任务和在终端里输入的消息,它不会拒绝任何东西。 - 自动模式,默认。 自动模式的会话以
--permission-mode auto启动:Claude Code 自己的安全检查会审视每次调用,运行安全的调用,拦下有风险的调用,这些调用会像在询问模式下一样,作为权限请求到达 Telegram。每个会话都以自动模式开始,除非这个聊天在/settings→ 新会话 里选了别的模式。任何能操作某个会话的人都可以在询问模式和自动模式之间切换它(/mode);aipager 在会话里按 Shift+Tab 就地切换,绝不使用可能把完全访问模式加入 Claude Code 的 Shift+Tab 循环的参数。如果 Claude Code 某次报告一个 aipager 并非以完全访问模式启动的会话处于完全访问模式,aipager 会显示报告的内容,在日志里写一条警告,并且绝不会因此以完全访问模式启动或重启会话。已知的限制:Claude Code 报告的模式是通过 hook 的套接字传入的,而电脑上的任何进程都能写入这个套接字(见网络连接)。一份伪造的报告可以改变会话显示的模式,并且在询问模式和自动模式之间的一次切换正在等待确认时,它可能让 aipager 多按一次 Shift+Tab,使正在运行的会话停留在自动模式,而用户要求的是询问模式(下一次真正的 hook 会重新显示真实的模式)。它绝不会改变重启或恢复时传入的参数,也绝不可能达到完全访问模式:只有启动时的参数才能设置这个模式。在 aipager 知道可能循环到完全访问模式的会话里,aipager 不会按 Shift+Tab:以完全访问模式启动的会话(即使之后在终端里切换到了询问模式或自动模式),或者自正在运行的 aipager 启动以来报告过完全访问模式的会话。在那里,每次切换模式都是一次重启,传入新模式的参数并去掉完全访问模式的参数,一份伪造的完全访问模式报告最多只能把一次切换变成一次重启。对于在终端里用把完全访问模式加入 Shift+Tab 循环的参数(完全访问参数前面的--allow-)启动的会话,或者用--dangerously-skip-permissions启动、但在 aipager 收到它的消息之前就已离开完全访问模式的会话(那时 aipager 没有运行,或者之后重启过),aipager 无法知道这一点。在这些会话里,从询问模式切换到自动模式可能会停在完全访问模式,这时卡片会显示会话处于完全访问模式。对于非管理员可以从 Telegram 切换模式的终端会话,不要使用这些参数。为了让完全访问模式能从 Telegram 使用,aipager 每次启动时都会在~/.claude/settings.json里设置skipDangerousModePermissionPrompt(否则 Claude Code 在以这个模式运行前的警告会让会话停住);这也会关闭你自己启动的会话的这条警告。同样,它会在~/.claude.json里把每个会话启动所在的文件夹标记为可信(除非这个文件夹或它的上级文件夹已经可信),这样 Claude Code 的“Do you trust this folder?”问题就不会卡住从 Telegram 启动的会话:第一条消息会用“No, exit”回答它。可信文件夹自己的.claude/设置、.git/钩子和.mcp.json都能运行命令,所以没有can_manage的成员永远不能写这些文件(除非policy.yaml把 shell 还给他们的角色)。Claude Code 会把一个文件夹的信任延伸到它下面不在 git 仓库里的文件夹;仓库仍然会单独提问。主文件夹在有会话从那里启动时被标记,因为 Claude Code 在主文件夹里得到的“是”只对那一个会话有效。此后,在你主文件夹下任何不在 git 仓库里的文件夹中,Claude Code 都不再提问,你自己启动的会话也一样。/和主文件夹的上级文件夹永远不会被标记:那样会覆盖电脑上所有这样的文件夹,所以在其中某个文件夹里启动的会话会停在这个问题上。aipager 启动时不标记任何东西,只标记即将有会话启动的那个文件夹。新会话在 aipager 运行的文件夹里启动(在 Linux 和 macOS 上用aipager service install时以及在容器里,就是主文件夹),设置了AIPAGER_WORK_DIR时则在它指定的文件夹里;如果那个文件夹是/或主文件夹的上级文件夹并且不可信,aipager 的日志会在启动时发出警告。之前的版本每次启动都会标记 aipager 的文件夹,macOS 服务(它运行在/)连/也会标记;aipager 无法区分这样的条目和你自己创建的条目,所以会保留它。当 aipager 无法完整读取~/.claude.json和~/.claude/settings.json,或者在它更新时有正在运行的 Claude 改动了它们,aipager 就不去动它们。 - 始终允许,把 Claude Code 随请求一起给出的“不要再问”规则交还给 Claude Code,和它自己对话框里的这个选项完全一样。
要对所有人锁定某个工具,在 ~/.claude/settings.json 里禁止它;要对某些人锁定它,用他们在 policy.yaml 里的角色规则(见群组)。
权限请求显示的就是将要运行的内容。 权限请求会在 Claude 的说明下面显示真实的命令或路径。Unicode 的方向控制字符(U+202A-U+202E、U+2066-U+2069、U+200E、U+200F、U+061C)会改变 Telegram、浏览器或手机绘制它们后面文字的顺序,而 shell 是按原本的顺序运行这些字符的,所以它们可以让一条命令看起来像另一条(“Trojan Source”手法,CVE-2021-42574)。在每个权限请求、问题、改动预览和审计记录里,它们都会显示为可见的标记(⟨U+202E⟩),权限请求还会警告这条命令运行起来可能和看上去的不一样。其他隐藏字符保持原样。
团队模式下的规则执行#
在团队模式下,aipager 会在点按之下再加一层:每条来自 Telegram 的消息都带一份权限备注(谁发送的,以及发送者的角色允许什么),写在 /tmp/claude-notes-<session>/ 里,每条消息一个文件。当 Claude 收到一条消息时,辅助程序 aipager-hook 会把它与这些备注比对,并把发送者的规则放到 PreToolUse 检查读取的地方。其余的一切都依赖以下这些特性:
- 最严格的规则胜出。 如果 Claude 在同一个任务里收到了好几个人的消息(aipager 会暂缓消息来避免这种情况,但即使发生了也是安全的),这个任务会按最严格的组合运行:所有者的消息绝不会把自己的权限借给别人。
- 正在运行的任务绝不会被放宽。 来自另一个人的消息会等正在运行的任务结束,然后作为单独的任务、按发送者的规则运行。如果一条消息仍然并入了正在运行的任务,这个任务会保留其中所有人最严格的规则;一条 Telegram 消息并入你在终端里输入的任务时,会让这个任务的剩余部分遵守发送者的规则(所有者的消息不改变任何东西)。
- 你的角色就是你正在操作的那个聊天里的角色。 一个出现在多个聊天里的人(比如在一个群组里是
user,在自己的私聊里是owner),在群组里发送或点按的一切都使用群组里的角色,在私聊里则使用私聊里的角色,与聊天在aipager.yaml里的顺序无关。 - 不明确就意味着受限。 不知道是谁发的消息会按内置的最低权限运行(没有绕过权限,所有禁止规则都生效),绝不会像不受限制的终端消息那样运行。你直接在终端里输入的消息仍然不受限制;任何一行带有 Telegram 标记的内容都会受到检查。每条 Telegram 消息都带有这个标记,写明发送这条消息本身的人(绝不是最近一个操作会话的人);aipager 不认识的发送者,例如在消息等待期间被从
aipager.yaml里删除的人,会得到不带名字的[via Telegram]标记和最低权限。 - 斜杠命令需要管理员。 斜杠命令会原样输入给 Claude,不带任何标记,而像
/deliver这样的命令或 skill 会根据它自己的内容运行一整个任务。所以,角色不能绕过角色规则的成员(内置的user和read_only、你自己创建的没有bypass_role_denies的角色,以及任何 aipager 不认识的人)只能发送 aipager“命令”键盘上的命令(另加/compact)和更换模型(/model <name>);其他任何斜杠命令,无论是直接输入、用/<name>发给某个会话、作为文件的说明文字、作为/new的第一条消息还是作为模板,都会被拒绝,并回复“这个命令仅限管理员。”和一个 🤷。所有者和管理员可以发送任何命令。从 Telegram 仍然到达 Claude 的斜杠命令依然会受到检查:hook 看到这个任务收到了一条发送这个命令的 Telegram 消息,就会让它遵守发送者的规则。你在终端里输入的斜杠命令仍然不受限制,除非同一个命令(带任何参数)之前在这个会话里从 Telegram 发送过:aipager 无法区分你的那一条和 Claude Code 重新发送的 Telegram 那一条,所以这时你的那一条会按最近一个 Telegram 任务的规则运行。这种情况在会话结束时终止。 - 没有新发送者的消息保持任务的规则。 在一个来自 Telegram 的任务运行期间,Claude Code 还会报告一些没有任何 Telegram 消息能解释的消息:在终端里输入并排在这个任务后面的消息(在排队时就会报告)、来自电脑上另一个 Claude 会话的消息,或者压缩对话之后重新发送的同一条 Telegram 消息。这样的消息不会增加任何 Telegram 发送者,所以任务保持它正在使用的规则,会被任何仍在等待接收的消息收紧,但绝不会放宽。一条带有 Telegram 文字、但 aipager 不知道是谁发的消息仍然会落到内置的最低权限,而权限更少的发送者的消息仍然会把任务降到那个发送者的规则。
真正约束受限用户的是什么。 Claude 会话以拥有 aipager 配置的同一个系统用户身份运行(~/.config/aipager/aipager.yaml 里的机器人 token、~/.config/aipager/daemon.env 里的 Claude 登录信息),也拥有它每个任务的规则文件(/tmp/claude-policy-<session>.json)。在这个用户下运行的 shell 可以用任何模式都预料不到的写法触及这两者(sed -i … /tmp/*-policy-*.json、对 glob 的 for 循环、一行 Python),把自己的规则改成所有者的规则。所以命令模式是过滤器,不是边界。对于在内置受限角色(user、read_only)下运行的任务,以及 aipager 不知道是谁的任务,aipager 执行以下规则,没有一条依赖命令模式(它们不涵盖什么,见下面的已知限制):
- 没有 shell。 他们不能使用
Bash,也不能使用 Claude Code 用来运行代码的任何其他工具(PowerShell、Monitor、REPL、Workflow、消息调度工具CronCreate和RemoteTrigger等;列表是safety.CODE_EXECUTION_TOOLS)。定时发送的消息到达时不带 Telegram 标记,也就是不受限制,所以这些工具也算在内。出于同样的原因,他们不能使用SendMessage(它可以把消息转给电脑上另一个 Claude 会话,在那里消息会像终端消息一样运行),也不能使用EnterWorktree/ExitWorktree(它们会改变会话的工作文件夹,而下一条规则依赖它)。 - 写文件限于项目内。
Write、Edit、MultiEdit和NotebookEdit只能写入会话的文件夹(Claude Code 在 hook 数据里报告的工作文件夹,绝不是工具调用里的路径)和会话的 Claude Code scratchpad(/tmp/claude-<uid>/<project>/<session id>/,无法确定时是/tmp/claude-<uid>/)。其他任何地方都会被拒绝:你的 shell 启动文件、~/.ssh、其他项目、aipager 在/tmp里的文件。在文件夹里,.claude/、.git/和.mcp.json也会被拒绝,因为它们每一个都会让 Claude Code 或 git 运行命令。在做出决定之前会先解析符号链接。文件夹是你的主文件夹、/或主文件夹上级文件夹的会话没有项目文件夹:在那里,受限的任务只能写入它的 scratchpad,在其他任何地方写入都会被拒绝,原因是“这个会话在主文件夹(或其上级文件夹)中运行,而这个角色只能在项目文件夹内写入”。为了不让受限成员困在那里,/new和 Mini App 不让他们在这些文件夹里启动会话(aipager 自己的文件夹,也就是新会话的默认文件夹,在作为后台服务运行时就是你的主文件夹);他们可以在你的主文件夹里新建一个文件夹并在里面启动(必须是新文件夹:不能是你在那里已有的文件夹,也不能是bin或node_modules,其他程序会从那里加载代码)。 - 成员留在自己的根文件夹里。 没有
can_manage的角色(内置的user和read_only)在聊天的根文件夹里工作,根文件夹由你在aipager config里设置(默认~/aipager-users/<label>;~/aipager-users和这个文件夹以 0700 权限创建)。/new、恢复、换成新会话、重启、切换模式和 Mini App 只会在这个文件夹里启动或重启这样的成员的会话。无论消息从哪条路来(Telegram、Mini App、来自另一个会话的消息),安全 hook 都会坚持这一点:根文件夹随任务的规则快照、会话间消息的回执以及固定或定时的权限一起走,当根文件夹不同的成员共用一个任务时,范围更窄的那个胜出(两个互不包含的文件夹只剩下会话自己的文件夹)。只有当会话的文件夹在根文件夹里时,才能写入会话的文件夹(在根文件夹之外的会话,例如在有根文件夹之前启动的会话,会继续运行,但它的任务只能写入 scratchpad:“这个会话在这个角色自己的文件夹之外运行”),并且Read、LSP、Grep和Glob不会触及你主文件夹里根文件夹之外的任何东西(“在主文件夹中,这个角色只能读取它自己的文件夹”),会话的 scratchpad 和它自己的回复文件除外。其他每个聊天的根文件夹对这个成员来说都是受保护路径,无论它在哪里;/tmp/aipager-files/下其他每个聊天的上传文件夹也是(对Read、Grep、Glob和写入工具而言;对被给了 Bash 的角色,写明其中某个文件夹的命令会被拒绝,尽力而为),在按聊天分文件夹之前所有聊天都放在那里的文件也一样。成员在自己聊天里上传的文件仍然可以读取。你主文件夹之外的文件夹(/usr、/etc、/srv)仍然可以读取。有bypass_role_denies或bypass_safety、但没有can_manage的自建角色,可以在它的根文件夹里的任何地方写入和搜索,它在你主文件夹里的读取也限于根文件夹之内:只有所有者和管理员不受限制。有bypass_safety时,在文件夹位于其根文件夹之外的会话里,它只能在 scratchpad 里写入和搜索,就像user一样,而且一旦它的某次工具调用被拒绝,任务的剩余部分就会被停止。 - 搜索限于项目内。 只有当以下条件全部满足时,
Grep或Glob才被允许:它搜索的文件夹(它的path,没有时是会话的文件夹,解析符号链接之后)在会话的文件夹里(当那是你的主文件夹、/或主文件夹的上级文件夹时不算,和写入一样)或在 scratchpad 里;这个文件夹不是受保护路径,也不包含受保护路径(在你的主文件夹里启动的会话包含~/.config/aipager,所以不能从它的根目录搜索);每个 glob 都是简单的相对模式(开头没有/、~、$或盘符,没有..,没有#(对 ripgrep 来说是注释)或!(取反),没有转义字符\);并且你没有任何不以/或~开头的受保护路径规则(**/.env可能出现在项目里的任何地方,所以有这样的规则时,每次受限的搜索都会被拒绝)。其他一切都会被拒绝。这是有意做成的允许名单:三轮审查每一轮都发现了一种新的 glob 写法,能把 ripgrep 带出解析模型所预测的文件夹,其中一种读到了机器人的 token。 - 文件工具遵守受保护路径。
Read、LSP和写入工具会检查一个路径实际指向哪里,包括通过符号链接。任务唯一可以读取的控制文件是那个会话自己的/tmp/claude-reply-<session>.txt,也就是你回复一条较早的消息时 aipager 在消息里写明的那个文件;其他会话的回复文件,以及对它的任何写入,仍然会被拒绝。在 macOS 上,/tmp是/private/tmp,而且磁盘不区分大小写,所以两种写法以及任何大小写组合都能被识别。 - 出错的检查会拒绝。 如果对一次工具调用做决定时出错(路径里有 NUL 字节、一个 bug),或者 hook 在做决定时内存耗尽,这次调用会被拒绝,除非会话的规则可以读取并允许所有者绕过。以前它会被放行。
你自己的受保护路径。 policy.yaml 的 safety: 部分(deny_paths_no_access、deny_paths_no_write、deny_bash_patterns)会加到内置的受保护路径和命令模式上。它是除 owner 之外每个角色的每个任务都会得到的安全底线的一部分,包括管理员,并且像内置列表一样适用于文件工具、搜索和 Bash 命令。aipager 把合并后的列表写到 /tmp/claude-policy-.floor-<uid>.json(它本身也是受保护路径),供安全 hook 的后备路径使用;如果这个文件不存在、无法读取或属于另一个系统用户,hook 会使用内置列表,绝不会更少。通过不重启的重新加载做出的更改,适用于重新加载之后发送的消息,不适用于正在运行的任务或正在等待的消息。直到 0.7.20,这一部分会被 aipager doctor --safety-check 读取并显示,但不会执行。
aipager 不知道是谁的任务。 一个没有任何发送者可以依据的、来自 Telegram 的任务,会按最低权限运行,相当于内置的 user 角色(不包括你在 policy.yaml 里给 user 添加的额外限制)再加上你的 safety: 部分:没有运行代码的工具,写入和搜索受到限制,受保护路径。这会发生在权限备注已过期(24 小时后)或无法写入的消息上,也会发生在带有 Telegram 文字、但没有任何备注能对上的消息上。“重试”按钮在点按者本人发送了被重试的消息时,以点按者的权限运行,所以所有者重试自己的消息时仍然保留 Bash;重试别人的消息,或者重试 aipager 不知道发送者的消息,会按最低权限运行,这样任何人的文字都借不到点按者的权限。
对于不受根文件夹约束的角色(有 can_manage 但没有绕过权限的自建角色),允许读取受保护路径之外的内容:它可以读取这个系统用户的其他项目。内置的 user 和 read_only 不能读取你主文件夹里它们根文件夹之外的任何东西(见上文)。对于没有 bypass_role_denies 的每个角色(内置的 user 和 read_only、你自己创建的角色,以及最低权限),存放你登录信息的文件都是受保护路径:~/.ssh、~/.gnupg、~/.aws、~/.config/gh、~/.git-credentials、~/.netrc、~/.docker/config.json、~/.kube、~/.config/gcloud、~/.azure、~/.password-store、~/.pgpass、~/.npmrc 和 ~/.pypirc 不能被读取、写入或搜索。这是角色的默认设置,所以某个角色在 policy.yaml 里自己的 deny_paths_no_access 会为那个角色替换它们,除非正在运行的任务又并入了另一条消息或在终端里输入的消息:从那时起直到任务结束,它会再次受这份列表约束(最严格的规则胜出)。其他地方存放登录信息的文件(项目里的 .env、另一个文件夹里的 token)不包括在内:请把它加到角色的 deny_paths_no_access 里。所有者不受这些约束。管理员的写入不受限制。他们的 Grep/Glob 可以在任何不触及受保护路径的文件夹里搜索:当它搜索的文件夹(它的 path,没有时是会话的文件夹;对于 Glob,还包括它的模式写明的文件夹,例如 /home/you/.config/aipager/*,或者不带通配符的模式上面的那个文件夹,Claude Code 会在任何深度查找那个名字)在解析符号链接之后是一个受保护的文件夹、位于这样的文件夹里,或者包含这样的文件夹时,搜索会被拒绝。所以在 ~、/、你主文件夹的上级文件夹或 /tmp 上运行 Grep 会被拒绝(它们包含 ~/.config/aipager、~/.claude 或 aipager 的 /tmp/claude-* 文件),而在一个项目或 /tmp/myproj 上运行 Grep 则不会。一条不以 / 或 ~ 开头的受保护路径规则(例如 **/.env)可以匹配任何文件夹,所以当它出现在管理员的规则里(或者在 safety: 部分里,除 owner 之外的每个角色都会得到这一部分)时,管理员的每次搜索都会被拒绝,和受限角色一样;aipager doctor 会对 safety: 部分里的这种规则发出警告。管理员有 Bash,所以他们的规则仍然只是尽力而为(见下文)。
已知限制。 以下情况不在保护范围内:
- 如果 hook 本身在开始做决定之前就失败了(例如读不到它自己的状态文件,或者在启动时内存耗尽),Claude Code 会把它当作“允许”。受限用户无法造成这种情况;做决定期间的任何错误都会导致拒绝。
- 不是文件工具、但能向外发送数据的工具不做路径检查:
WebFetch(任务能读到的任何东西,它都能发送到一个网址),以及 Claude Code 的其他发送工具。如果这对你很重要,就在policy.yaml里对某个角色禁止它们。把文件发到聊天的SendUserFile会做检查(见 Claude 发送的文件)。 - 受限任务启动的后台代理在任务结束后会继续运行,它之后的工具调用按会话里最新的那条消息来判断:之后来自所有者或终端的消息会解除它的限制。
- 你创建的有
can_manage、但没有任何绕过权限的角色不受根文件夹约束:它仍然可以在它的聊天已经工作过的任何项目文件夹(之前的会话用过的文件夹)里启动,包括你的文件夹,并读取你的其他项目。 - 无法归到任何成员身上的任务(发送者不明、已删除的成员、来自另一个会话但无法核实发送者的消息)会按受限的最低权限运行,而最低权限没有根文件夹。在读取方面,它比内置的
user更弱:这样的任务可以读取你主文件夹里任何不是受保护路径的文件(例如你的其他项目),而user的任务只能在它的根文件夹里读取。这样的任务还可以读取/tmp/aipager-files/里每个聊天的上传文件,而user的任务只能读取它自己聊天的上传文件。它的写入仍然限于会话的文件夹和 scratchpad。来自某个成员的会话、经过核实的消息会带上这个成员的根文件夹。 - 根文件夹只约束文件工具。你给了
Bash的自建角色可以通过 shell 读取根文件夹之外的内容(内置的user和read_only没有Bash)。有bypass_safety、但没有can_manage的自建角色,除了文件工具的路径之外,其他一切都保留绕过权限:没有任何工具或命令模式会对它禁止。 - 写明另一个成员根文件夹的拒绝原因,会把那个文件夹的路径透露给 Claude 并显示在聊天里。
- 根文件夹出现时(一次升级)已经在运行的成员任务,在那个成员发送下一条消息之前没有根文件夹。
- 在项目里,受限用户可以修改你之后自己运行或读取的东西(源码、
Makefile、package.json里的脚本、conftest.py、你自己的任务会加载的CLAUDE.md)。
有 Bash 的角色(admin,它会绕过角色的禁止规则,或者任何被 policy.yaml 重新给了 Bash 的角色:allow_tools 里写了 Bash,或者替换了 deny_tools)只受尽力而为的规则约束:也就是下面的命令模式,一个下定决心的人可以绕过它们。对成员拥有的每个这样的角色,aipager doctor 会警告一次。只把 Bash 交给你愿意把这台电脑的 shell 交出去的人。
内置的命令规则。 在所有者之外、每个有 Bash 的 Telegram 任务都要遵守的 Bash 模式里,有一条会拦下 claude 这个词:它用一条规则同时拦下嵌套的 claude(无论路径、包装或参数)和对 ~/.claude 的任何访问,并且还能拦下 aipager 自己的 /tmp/claude-* 控制文件的直接名称,因为它们的名称有相同的前缀(对这些文件来说,模式并不是真正的保护:用 glob 的写法就能避开)。唯一的例外是 Claude Code 创建的 scratchpad 文件夹 /tmp/claude-<uid>/…:正是这个文件夹,作为一个完整的路径部分,被豁免,所以从会话的 scratchpad 运行的命令不会被拦下。这个文件夹属于系统用户,而不是某一个会话,所以有 Bash 的任务可以读取那里其他会话的 scratchpad 和任务输出,就像 Read 工具也能做到的那样。它的 glob 或形似的名称(/tmp/claude-1*、claude-1000x)、其他临时文件夹,以及从它能到达的任何地方(…/../../home/you/.claude)仍然会被拦下。这个词的其他用法(git log --grep=claude、名为 claude-notes.md 的文件、pip show claude-agent-sdk)也仍然会被拦下,这是有意的:同一个词既是程序的名字,也是它的 SDK 和 aipager 控制文件的名字,而模式无法区分它们。你在 policy.yaml 里添加的规则看到的是原样输入的命令,包括 scratchpad。
通过 Bash 搜索。 有些 Claude Code 版本没有 Grep 或 Glob 工具(2.1.289 就是一个),这时 Claude 会通过 Bash 用 grep、rg 或 find 搜索,上面关于 Grep/Glob 的规则在那里不适用。对除 owner 之外的每个角色,命令模式还会禁止对整个主文件夹或 / 的递归搜索或列出:用 grep -r(-R、--recursive、-d recurse、egrep、fgrep、rgrep)、rg(以及 rg --files)、ag、ack(以及 ack -f)、find、fd、tree、du 和 ls -R 处理这些文件夹:~、~user、$HOME、${HOME} 或 "$HOME"、/home、/home/<user>(或 /home/$USER)、/Users、/Users/<user>、/root 或 /(末尾带或不带 /、/. 或 /*),以及存放 aipager 文件夹的 ~/.config 或 ~/.local。在 cd x && 之后、在管道里、在 bash -c "..." 里面、在 xargs、env 或 timeout 之后,以及在 LC_ALL=C 这类设置之后,这条规则同样有效。回复会建议改在项目文件夹里搜索。在项目文件夹里搜索(grep -rn x ~/proj、rg x src/、find . -name y)、在项目里搜索主文件夹路径这个文字(grep -rn "$HOME" src,选项之后的第一个词是要找的文字,不是文件夹),以及非递归的搜索(grep x ~/.bashrc、ls ~)都不受影响。和所有命令模式一样,这是尽力而为:模式看到的是词,而不是 shell 在哪里运行它们,所以它看不到从主文件夹(或其下)运行的、没有文件夹或者文件夹是 . 或 .. 的搜索(cd ~ && grep -r x .),通过另一个变量或 glob 到达的主文件夹,在 /home、/Users 和 /root 之外完整写出的主文件夹,也看不到 \grep,或者紧跟在以 -e 或 -f 结尾的词后面的文件夹(会被当作 -e/-f 交给 grep 和 rg 的文字,所以 ag x -f ~ 能通过)。它也不知道哪些选项带有单独的值,所以 rg -t py "$HOME" . 或 grep -rn -A 3 "/home/you" src 会把 py 或 3 当作要找的文字、把主文件夹路径当作文件夹,因而被拒绝(请写成 -A3、--include=*.py,或者把选项放在要找的文字后面),在选项组合里交给 -e 的主文件夹路径也一样(grep -rne "/home" src;-r -n -e 则没问题);引在另一条命令文字里的命令(echo "a; rg x ~")也会被拒绝,du -sh ~(只看大小)也是。
为什么一次拦截会停止整个任务。 一次工具调用被拦下之后,同一个任务里之后的每次调用也都会被拒绝,直到下一条消息。如果不这样,代理只要换个写法(一个 glob、另一条命令、一个编码过的路径)反复尝试同一个操作,直到有一种写法从模式中溜过去。停止任务让第一次尝试的代价变高;它并不会把模式变成边界,因为第一次尝试的写法可能正好就是能溜过去的那一种。误拦的代价是一个被停止的任务,这就是上面的规则保持很窄的原因。
这些备注所携带的规则在用户这一侧是什么样子,见群组 → 规则如何运作。
Claude 发送的文件#
aipager 启动的每个会话都有 Claude Code 的 SendUserFile 工具(CLAUDE_CODE_ATTACHMENTS_BY_PATH=1),它会读取文件并发送到会话的聊天(命令)。PreToolUse 钩子在 Claude Code 读取文件之前检查这次调用的每个文件:
- 发送底线,适用于每个任务,owner 的任务和在终端输入的消息也包括在内:aipager 的配置和状态(
~/.config/aipager/**、~/.local/share/aipager/**、~/.local/state/aipager/**以及它在/tmp里的控制文件),整个~/.claude/**和~/.claude.json*(Claude 的凭据和账户),所有受限角色都不能碰的凭据文件(~/.ssh/**、~/.gnupg/**、~/.aws/**、~/.git-credentials、~/.netrc以及这个列表的其余部分),/proc/**、/sys/**、/dev/**,以及不论在哪里、看起来像机密的名字(.env、.env.*、*.pem、*.key、id_rsa*、id_ed25519*和其他密钥名、*.p12、*.pfx、*.kdbx、*.jks、*.keystore、.pgpass、credentials、*credentials*.json)。路径按写法和实际指向都要比对,所以指向其中之一的符号链接也会被拒绝。有些无害的文件也用这些名字(Keynote 的*.key、.env.example),同样会被拒绝。 - 文件本身:解析符号链接后是普通文件,只有一个硬链接,不为空,最大 50 MB。
- 任务自己的规则:任务不能读取(Read)的内容;对成员(没有
can_manage、限定在根文件夹里的角色)还有更严格的规则:每个文件按写法和实际指向都必须在根文件夹内。和成员读取文件不同,这里对主文件夹以外的文件(/tmp、/etc)和会话的 scratchpad 都没有例外。
拒绝是温和的拒绝:Claude 会读到原因("SendUserFile refused, nothing was sent: ... Tell the person which file could not be sent and why."),任务继续进行;不会像工具被拦截时那样停下会话。检查本身出错时会拒绝该文件,owner 也一样。每个被拒绝的文件在审计日志里都有一条 FileRefused 记录。
说到文件,aipager 不会轻信控制套接字:任何本地进程都能写入它,并随意指明任何会话、任何调用或任何对话记录,而且单是一次工具调用的 hook,就会让一个空闲的会话变成工作中。所以,只有在 aipager 看到以提示开始的任务(它从 Telegram 输入的提示,或一个 UserPromptSubmit)以及该任务的后台工作进行期间,调用才算数,直到会话空闲且没有任何工作在运行为止;任务开始时,aipager 会记下时间和它为该会话跟踪的对话记录(同一任务后来的 UserPromptSubmit 会重新指明对话记录)。aipager 输入的斜杠命令不会开始任务(会运行任务的命令会自己发出 UserPromptSubmit),而从未开始的任务(15 秒后会话仍空闲且没有工作)会再次关闭。Claude Code 开始某些任务时没有 UserPromptSubmit:任务运行期间发来、被它当作下一个任务的消息,以及 Stop hook 让它继续的任务(/goal)。这样的任务在以下情况下算数:那条消息是 aipager 自己输入的,或者它记下的对话记录显示该任务在上一次 Stop 之后开始(一行队列 dequeue,或 Claude Code 写下的 Stop hook feedback: 那一行);在此之前,调用的任何内容都不会被读取,最多等 20 秒。文件列表取自它检查过的 PreToolUse,或者在漏掉它时(重启之后)取自 PostToolUse;从不使用 Claude Code 的结果。每次调用只复制一次:已经复制过的调用 id 不会再被复制或发送(每个会话记住最近 256 个,重启后也记得)。 它只找一次每个文件的真实路径,并在这个路径上再次检查(底线、文件检查和会话的根文件夹),打开时就打开这个路径、不跟随链接,确认打开的就是检查过的那个文件、而且它仍在这个路径上,然后复制到 ~/.local/state/aipager/outbox/<chat>/(文件夹 0700,文件 0600),所有受限任务本来就不能碰这个地方。 发送之前,它会在为该任务记下的那份对话记录里找这次调用(子代理的调用,则在旁边那个子代理自己的对话记录里找),从不在工具调用的数据报指明的记录里找:一行助手的内容,带有这个 id、文件正好是这些文件的 SendUserFile tool_use,从同一个文件夹写入,时间不早于任务开始,位于 ~/.claude/projects(或为 aipager 自己设置的 $CLAUDE_CONFIG_DIR/projects)下、属于 aipager 自己的系统用户的 .jsonl 文件里。Claude Code 写对话记录会晚一些,所以 aipager 最多等这次调用 20 秒;只要会话的工作还在运行(后台代理、工具调用),它就保留这些文件,稍后再找。等到什么都不再运行时仍然找不到的调用不发送,副本也会被删除;在没有这样的任务运行时到达的调用,或者第二次到达的调用,根本不会被复制。这些调用的每个文件都会在审计日志里留下一条原因为 unverified 的 FileFailed 记录。只有当 Claude 自己的对话记录显示任务期间确有这次调用时,聊天才会收到一行(⚠️ 无法发送 report.pdf:aipager 无法确认这是 Claude 在这个任务中发送的文件。),而且这一行写的是对话记录那一行列出的文件,所以伪造的调用既放不进一行,也放不进一个名字;否则聊天里什么都不发。发送的只是副本,只发到会话自己的聊天,在任务的回答之后、在该会话更早的文件之后,而且不会拖住下一个任务;副本发送后立即删除,或一天后删除。
这些检查管不到的情况:以 aipager 自己的系统用户运行的进程,既能写套接字,也能写 Claude 的对话记录(它本来就能自己读这些文件)。能连到套接字的其他用户的进程也可以伪造一个 UserPromptSubmit:对这些检查来说,它开始了一个任务,并指明了该任务的对话记录。之后,如果某次真实的 SendUserFile 调用在那一刻之后从同一个文件夹写进了这份对话记录(比如另一个会话恰好在那时进行的调用),它就能让 aipager 把这次调用指明的文件发到该会话的聊天,每次调用一次。为此它还必须发出这次正在进行的调用自己的 id(tool_use_id),而其他用户读不到它:它在 aipager 用户的 ~/.claude 下的对话记录里,hook 从输入中拿到它,而不是从命令行。伪造的 Stop 对这些检查来说会结束任务,所以该任务之后发送的文件不会发出。用与 aipager 不同的 $CLAUDE_CONFIG_DIR 启动的会话,或者 aipager 漏掉了 UserPromptSubmit 的任务(在 aipager 重启期间于终端里输入的任务),都发不出文件。Claude Code 以其他方式在没有 hook 的情况下开始的任务也不会发送文件(已知的只有上面两种);对话记录显示 Claude 在那里请求过的文件会收到上面那一行。 设置了 $XDG_RUNTIME_DIR 时,套接字在这个其他用户连不到的地方;没有设置时,它在 /tmp 里。
审计日志#
每次点按允许 / 拒绝,以及每个对 AskUserQuestion 的回答,都会向 ~/.claude/aipager-audit.jsonl 追加一行 JSON:
{
"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
}
字段(完整列表见 aipager/audit.py):
ts:ISO 8601 格式的 UTC 时间戳,精确到秒。session/label:内部名称和易读的名称。action:发生了什么(Allowed、Denied、Allowed always、一个AskUserQuestion回答、一次自动拒绝等)。tool/summary:工具名称和截短的输入,或者问题的内容。user_id/username:谁点按的,用于在团队模式下知道谁做了什么;配置了多个聊天时还有scope_label/scope_chat_id。denied:对每次拒绝为 true,无论是点按造成的还是规则造成的。
写入是尽力而为的。如果磁盘满了或者 ~/.claude/ 无法写入,aipager 会在日志里写一条 WARNING 并继续运行:不会悄无声息地丢失,也不会崩溃。见 aipager/audit.py。
磁盘上的审计日志只追加。加上聊天里的审计回复(每个决定一条 Telegram 消息,位于工作卡片下方),你就有了两份互相独立的记录。
权限边界#
aipager 从不提升权限。没有 sudo,没有 setuid,没有 doas。它写入的每个文件都在你的主文件夹里、在 /tmp 里(aipager 每个会话的控制文件、下载的文件和重启日志),或者在存放它控制套接字的文件夹里($XDG_RUNTIME_DIR);完整列表见架构。每个子进程(claude、dtach、安装程序、claude update)都以 aipager 的用户身份运行。
从 Telegram 安装语音消息功能([📦 安装语音消息功能])会通过安装 aipager 的那个安装程序进行(uv tool install --reinstall aipager[voice]、pipx install --force aipager[voice]),否则就用 aipager 自己的 Python 安装这个功能的软件包(<python> -m pip install):总是装进 aipager 自己的环境,绝不会装进系统的 Python。
[🔄 重启 aipager] 按钮在 systemd 服务下,会在一个单独的临时用户服务里安排 systemctl --user restart aipager.service;在 macOS 上,它运行 launchctl kickstart -k;你自己启动的 aipager 会创建一个自己的独立副本,这个副本会等待旧的那个退出。更新之后,只有 systemd 服务会自动重启(见命令 → 更新)。一切都以同一个用户身份运行;没有提升权限。
网络连接#
aipager 不在回环地址之外监听任何 TCP 端口。Mini App 服务器只绑定 127.0.0.1(在代码里写死:有意不提供主机选项),只能通过下面介绍的托管隧道从外部访问。向外的连接:
- 到
api.telegram.org的 HTTPS 长轮询(Telegram 机器人接收更新)。 - 到
telegram.org的 HTTPS,用于获取 Mini App 的 SDK 脚本(telegram-web-app.js),在 Mini App 服务器启动时下载一次,最多每天刷新一次;缓存在~/.local/share/aipager/webapp-sdk/。aipager 从页面自己的来源把这些字节提供给页面,自己从不运行它们。aipager 不会再分发这个脚本:安装包里没有附带它,当 aipager 没有副本时,页面会像以前一样直接从telegram.org加载它。 - Mini App 开启时(默认开启)到 Cloudflare 的隧道。它运行的
cloudflared程序从github.com/cloudflare/cloudflared的发布版本下载一次,用一个固定的 SHA-256 校验,并缓存在~/.local/share/aipager/cloudflared/;你PATH上的cloudflared从不会被使用。使用你自己的 Mini App 网址时(见下文),这些都不会发生。 - 版本检查,只在有人点按检查更新或更新时进行(
/update、Mini App 的“更新”部分),或在有权更新的人打开 Mini App 的设置标签页时进行(每个打开的应用最多每十分钟一次):aipager 查pypi.org,Claude Code 根据安装方式查downloads.claude.ai或registry.npmjs.org。之后,一次更新会运行安装程序(与 PyPI 或 Homebrew 通信)和claude update。命令行里的aipager update只运行安装程序。 - 安装语音消息功能,在你点按它的按钮时:安装程序从 PyPI 下载软件包,语音识别模型在第一次使用时从 Hugging Face(
huggingface.co)下载。 - 到
raw.githubusercontent.com(报告密钥文件)和 Sentry 接收服务器的 HTTPS,只在你确认发送问题报告时进行:你预览过的报告,以及由它生成的一份简短摘要,别无其他。见问题报告。
向内的连接:
$XDG_RUNTIME_DIR/aipager.sock上的 Unix 数据报套接字(没有设置$XDG_RUNTIME_DIR时退回到/tmp/aipager.sock)。由 aipager 在启动时绑定并用chmod设置权限(aipager/dtach/hook_receiver.py)。权限是0o666,这样电脑上的任何进程都能向它发送 hook 事件:与~/.claude/settings.json的信任级别相同,那个文件本来就决定了什么会运行 hook。
这意味着:aipager 不是一个远程攻击面。网络上的攻击者如果不先在这台电脑上取得立足点,就接触不到它。
Claude 的文字和 Telegram 自己的下载。 Telegram 的服务器会自己去下载消息里的某些地址:富文本消息里的图片、视频或音频块,以及链接预览背后的网页。Claude 的回答、它在工作卡片上写的话、代理的描述和工具参数,都按 Claude 写的原样到达 Telegram,所以一次提示词注入可能让 Claude 写出一个把机密带出去的地址,不需要任何工具调用,也没有任何权限请求。aipager 堵住了第一条路,第二条路则在 Telegram 提供了开关的地方堵住:
- 在每条富文本消息里,写成
的图片会作为一个普通链接连同它的替代文字发送,其他任何图片写法,以及原样的<img>、<video>、<audio>、<source>、<picture>、<figure>、<iframe>、<embed>、<object>、<tg-document>、<tg-collage>、<tg-slideshow>、<tg-button>和<tg-button-row>标签,都会显示成文字(aipager/bot/media_guard.py)。在代码块里(例如一段 HTML),这会多显示一个\或<。 - aipager 发送或编辑的每条纯文本和 HTML 消息都关闭了链接预览(包括回答和卡片的纯文本后备版本),它自己的消息也一样。它的 Mini App 链接是按钮,Telegram 不会去抓取。
Telegram 没有为富文本消息提供任何预览选项的文档;它会不会为这样的消息生成链接预览,还没有在实际服务上验证过。
Mini App 隧道#
Mini App 默认开启,所以在标准安装上,aipager 会在没有人要求的情况下打开这条隧道。这是一个有意的取舍(一个需要自己开启、却没有人发现的选项,就等于一个不存在的功能),但这意味着新安装会连接到互联网。用 aipager miniapp disable 关闭它,或者在 aipager.yaml 的 miniapp: 下设置 enabled: false;明确写出的 false 总是会被遵守。注意,缺失或损坏的 miniapp: 块现在会被理解为开启,而不是关闭。
Mini App(除非用 --url 代替)会启动一个托管的 Cloudflare quick tunnel,指向 aipager 自己的回环服务器,并把它得到的公开地址 https://*.trycloudflare.com 发布为 Telegram 的菜单按钮和 /app 的回复。
这个地址不是机密,也不需要是机密:Mini App 提供的每个请求都会用 Telegram 的 initData 签名校验(见上面的信任边界),无论网址是怎么得到的,保护它的都是这同一项检查。仅仅知道主机名,攻击者得到的并不比随便猜一个 trycloudflare.com 子域名更多:签名检查才是真正的大门。像对待任何不公开但也不机密的网址那样对待它:让它运行没问题,但没有理由就不要贴到公开的频道里。
主机名只保存在内存里:它绝不会写入 aipager.yaml 或 ~/.config/aipager/ 下的任何地方。计划内的重启(更新后的重启、立即重启、没有服务时的原地重启)会让隧道继续运行,所以地址、菜单按钮和已打开的 Mini App 都照常可用:下一个 aipager 只有在确认是同一次开机、同一个端口、进程正是那个 cloudflared(看它的命令行)、它报告的仍是同一个地址(~/.claude/aipager-tunnel.json 只保存地址的哈希,从不保存地址本身)并且这个地址能应答之后,才会接管它。其他任何情况都会停掉它并启动一个新的;保留下来的隧道如果 3 分钟内没有 aipager 接管,会被一个小的看守进程停掉。其他任何停止方式(Ctrl+C、docker stop、systemctl stop)都会停掉隧道,重启电脑或容器会得到新地址。MINIAPP_KEEP_TUNNEL=0 会关闭保留。如果 aipager 被直接杀掉(内存不足、kill -9),隧道会一直运行,直到 aipager 在开启托管隧道的情况下(启用 Mini App、没有 MINIAPP_PUBLIC_URL)再次启动并接管或停掉它,或者直到电脑重启;aipager uninstall 也会停掉它。如果隧道中途断了,aipager 会以逐渐拉长的间隔重启它,并重新发布新地址;如果试了几次仍然起不来,按钮会被移除,而不是指向一个已经失效的地址。
设置 MINIAPP_PUBLIC_URL(或 aipager miniapp enable --url https://…)会完全关闭托管隧道(cloudflared 程序从不会被下载或运行),给出的网址会原样使用,背后是同样的 initData 检查。对于已经在运行 Tailscale、完全不想依赖 Cloudflare 的人,自动检测 Tailscale(tailscale status --json)仍然是更简单的选择。
语音消息转文字#
faster-whisper 在进程内运行。音频以 .ogg 格式下载到 /tmp/aipager-files/ 下这个聊天自己的文件夹里(上传文件也放在那里,0700,文件本身 0600),在本机用 CPU 转成文字,文件始终在你的掌控之中。没有任何音频离开这台电脑。 没有第三方 API。除了与 Telegram 通信本来就需要的机器人 token,不需要任何密钥。
如果你在转写后删除了 .ogg 文件,这条消息唯一的文字记录就是输入到 claude 会话里的转写文本(在那里它遵循 Claude Code 的隐私政策)。
会话之间的隔离#
每个 Claude Code 会话都在自己的 dtach 里运行。/tmp/claude-dtach-<name>.sock 上的控制套接字属于 aipager 的用户;dtach 拒绝来自其他用户的连接。每个会话都在自己的文件夹里启动,这个文件夹通过 /new、Mini App 选择,或者是你运行 aipager session 时所在的文件夹。
aipager 不在文件系统层面隔离会话:一个绑定在 ~/projects/foo 的会话,原则上只要 claude code 的权限允许,就能读取 ~/projects/bar。要更严格的隔离,请为每个项目使用单独的 ~/.claude/settings.json,或者使用容器(Docker 镜像)。
Claude 会话之间的消息#
Claude Code 允许一个会话给另一个会话发消息(它的 SendMessage 工具)。在接收的会话里,这条消息和其他任何消息一样,所以 aipager 用决定 Telegram 消息权限的同样方式来决定它的权限:依据真正发送它的是谁,绝不依据它说了什么。
- 消息带着发送者的权限,绝不更多。 当 aipager 允许一个会话发送消息时,它会写一张一次性的回执:这段确切文字的指纹、发送消息的 Claude 进程(它的进程号,以及在 Linux 上它的启动时间),以及那个任务拥有的权限。Claude Code 记录收到的消息时会附上发送者的进程号,这个进程号由内核核实,绝不相信发送者对自己的称呼。在接收的任务运行任何工具之前,aipager 会把这两者对上,并给这个任务与发送任务完全相同的权限:所有者的消息以所有者的权限运行,成员的消息以那个成员的权限运行,在发送会话的终端里输入的消息则不受限制。
- 没有回执,就是受限的权限。 aipager 无法对上的消息(由 aipager 没有运行的 Claude 会话或进程发送、回执在一小时后已过期、发送者在另一台电脑上)会按最严格的规则运行,再加上不能修改文件、不能访问网络(只能阅读和思考)。接收会话自己之前的权限绝不会适用于它。
- 来自会话之外的其他输入,也一样。 凡是 Claude Code 记录为并非来自这个会话自己的人的内容,也会得到这些受限的权限:MCP 频道的推送、远程调用、插件排入队列的消息、从另一个会话的收件箱转来的消息,以及任何 aipager 还不认识的输入类型(插件命令算作外部输入,即使是你自己启动的)。会话自己的后台代理(它们交回的内容和报告)以及它的任务通知,保持整项工作的权限。
- 会话定时发送的消息,以安排它的那个任务的权限运行。 当一个任务设置
/loop或 cron 时,aipager 会为这条消息记录那个任务的权限;它之后触发时,得到的就是这些权限,绝不更多,无论最后说话的是谁;并入那个任务的消息只能收紧它,拿走安排者权限的规则变更也会收紧它。同一条消息被安排两次时,保留更严格的权限。不带内容的/loop触发时使用的是 Claude Code 的循环指令,而不是它自己的文字,所以一旦保存的对话里显示出这次触发,它会得到这个会话设置过的所有不带内容的循环中最严格的权限(在此之前,被拒绝的工具调用会被温和地拒绝,Claude 会再试一次)。没有经过 aipager 检查就被安排的消息会得到受限的权限。 - 文字证明不了任何东西。 消息里的发送者名字、
[via Telegram · @owner]这样的一行或“所有者要求…”都不会改变任何东西。Claude 会被告知 aipager 核实的发送者是谁,以及文字里的声明不是证据。 - 并入正在运行的任务只会收紧它。 在一个任务运行期间到达的、来自另一个会话的消息,会让这个任务变得和两者中更严格的那一方一样严格,绝不会更宽松。当 Claude Code 在没有它自己的消息事件的情况下接收这条消息时(并入正在运行的任务,或者作为队列里的下一个任务运行),这一点同样成立:所有者的快速通道会在每次工具调用之前查看保存的对话末尾,而任务结束时和 aipager 在看到队列里有这样的消息时,都会把会话标记为需要检查。
- 角色留在自己的聊天里。 来自另一个聊天的会话的回执,只有在它的任务拥有所有者的权限、或者是在它的终端里输入的时候才算数;其他一切都以受限的权限运行。
- 谁可以发送。 内置的受限角色完全不能使用 SendMessage。被规则允许使用它的角色,只能发给 aipager 运行的会话(地址形如
uds:…/<pid>.sock);aipager doctor会对这样的角色发出警告。 - 后台代理保留自己的权限。 代理保留启动它的那个任务的权限(或者之后某个任务更严格的权限),绝不会得到之后某个权限更多的任务的权限。
- 当它被停止时。 如果另一个会话发起的任务被安全规则停止,工作卡片本来就显示了是谁发送的,停止通知也会这样说,交出这项任务的人也会得到通知,这样它绝不会悄无声息地消失:当两个会话在同一个聊天里时,通知发到发送会话的聊天;否则只通知所有者,在私聊里,并且只在所有者(或终端)操作了发送任务的时候。
已知限制:
- 有 shell 的任务(所有者的或管理员的)以与 aipager 相同的系统用户身份运行,可以伪造回执,就像它本来就能改写 aipager 的其他控制文件一样。这是现有的信任模型:只把 shell 交给你愿意把整台电脑托付出去的人。
- aipager 没有运行的 Claude 会话完全不受 aipager 管理,无论是谁给它们发消息。
- 在 Linux 之外,只按进程号匹配发送者(Claude Code 在那里不记录启动时间)。在 Linux 上,没有启动时间的接收记录不会与任何东西匹配。
- Claude Code 可能在任务的第一次工具调用之后稍晚一点才把消息写进保存的对话。在它写入之前,受限权限会拒绝的调用会被温和地拒绝(Claude 会被告知再试一次;任务不会被停止)。8 次调用之后仍然没有写入,任务就以受限的权限运行。
- 所有者的快速通道读取保存的对话末尾的 1 MiB,绝不更多。比这更长的、来自另一个会话的消息,会以一行被快速通道当作这类消息的内容结尾(被这个窗口截断的、超过 256 KiB 的任何一行都是如此)。完全位于更早位置的消息(在下一次工具调用之前,保存的对话在它之后又多写了超过 1 MiB),会在任务运行期间由 aipager 发现,或者在它仍在队列里时由任务结束时的检查发现,而不是由快速通道发现。
- 当一个会话设置了不带内容的
/loop时,任何不是由 Telegram 消息开始的任务(包括在终端里输入的消息)在最初的几次工具调用时都可能被温和地拒绝,直到 Claude Code 写入了这个任务的消息,aipager 能分辨出它不是循环的触发;之后 Claude 会再试一次。 - aipager 根据保存的对话自任务开始以来新增的内容来读取定时消息所属的任务;超过 8 MiB 时,读取最前和最后的各 4 MiB(中间的部分在它还是最新部分时已经检查过)。
- 为定时消息记录的权限会一直保留,直到会话被结束(
/kill):已结束或被恢复的会话会保留它们,就像 Claude Code 会保留它的 cron 和等待中的/loop唤醒一样。每个会话最多保留 256 条记录,超过之后再安排定时消息会对会话里的所有人拒绝,所以任何可以安排定时消息的人都能把它们用完(那时请用另一个名字启动会话,或者/kill这个会话)。没有记录就触发的消息会得到受限的权限,在设置了不带内容的/loop的会话里则得到那个循环的权限,前提是 Claude Code 已经把它写进保存的对话;在那之前,aipager 按它之前的那个任务来读取这个任务。这会发生在这个版本之前设置的消息上;发生在电脑重启之后(记录保存在系统的临时文件夹里,重启会清空它,而 Claude Code 仍可能在一小时内把被恢复会话的唤醒带回来);也会发生在持久的 cron 上,当它自己的会话结束后,Claude Code 会在同一个文件夹的另一个会话里运行它。一个权限更少的会话安排相同的文字,可以降低一条消息的权限(绝不会提高)。 - 用之前某个会话的名字重新启动的会话(换成新会话)会保留那个会话的记录,包括它不带内容的
/loop的权限,直到/kill。 - Claude Code 在另一个任务之后紧接着运行、没有自己的消息事件的定时消息(来自它的队列),会得到它自己的权限和它所跟随的那个任务的权限两者中更严格的那个(跟在终端里输入的任务之后时,就是它自己的权限)。
- 在终端里输入的任务,在 Claude Code 把它的消息写进保存的对话之前,最初的几次工具调用可能会被拒绝(在那之前,aipager 按它之前的那个任务来读取它)。
- 以更窄的权限启动的后台代理,可以把文字交回同一项工作里之后的某个任务,那个任务以整项工作的权限读取这些文字,和 Claude Code 的其他任务通知一样。
- Claude Code 可能会在接收方的终端里扣住一条消息等待批准(它的
crossSessionInbound设置,或者当两个会话处在不同的权限类别时,例如完全访问模式和自动模式)。aipager 无法从 Telegram 放行这样的消息;回执最多等待一小时。
威胁模型摘要#
| 威胁 | 对策 |
|---|---|
| 陌生人给机器人发命令 | 聊天 ID 过滤器拒绝 |
| 机器人 token 被盗 | 在 @BotFather 里 /revoke,重新配置 |
| claude 的工具调用被利用 | aipager 的安全检查拒绝来自 Telegram 的任务的规则所禁止的东西;在询问模式下,Claude Code 自己在 settings.json 里的规则决定它询问什么,一个人的点按是唯一的允许方式;在自动模式下(每个新会话的默认模式),Claude Code 自己的安全检查会不经询问地运行它认为安全的调用,并拦下有风险的调用交给人来决定;完全访问模式的会话(管理员,以及在自己私聊里的人)什么都不问,所以只剩 aipager 的安全检查挡在路上,而对于所有者自己的任务和在终端里输入的消息,什么都挡不住(谁来决定) |
| 受限的 Telegram 用户获得所有者的权限 | 没有 shell,会话只能在成员自己的根文件夹里启动,写入和搜索限于其中的项目(绝不是主文件夹),不能读取主文件夹里根文件夹之外的内容,其他成员的根文件夹和其他聊天的上传文件无法触及,存放登录信息的文件不能读取,文件工具遵守受保护路径,出错的检查会拒绝(团队模式下的规则执行);对被给了 Bash 的角色只是尽力而为,并且不包括“已知限制”里的情况 |
| 一个会话的消息自称拥有所有者的权限 | 消息以发送任务经过证明的权限运行,绝不以接收会话之前的权限运行;没有证明就只能阅读和思考(会话之间的消息) |
| 群组成员借用别人的权限 | 每条消息都以发送者本人在消息来源聊天里的角色运行;另一个发送者的消息要等正在运行的任务结束;如果仍然并入了,最严格的规则胜出;受限角色的斜杠命令仅限于键盘上的命令(团队模式下的规则执行) |
| 群组里的闲聊进入会话 | 在群组里只处理命令、对机器人的回复、提及机器人的消息和带标记的键盘点按(群组) |
| 已删除的成员仍然保有访问权限 | aipager config 会在不重启的情况下重新加载 aipager;这个人被暂缓的消息会被丢弃,其仍在运行的任务会失去原来的权限;把这个人移出 Telegram 群组后,对方就再也读不到群组了(群组 → 不重启地重新加载) |
| 篡改审计日志 | 只追加;没有单独的签名程序,完全防止不在范围之内 |
| 提示词注入让 Claude 写出一个把数据带出去的地址 | Claude 文字里的图片和媒体标签会显示成链接或文字,绝不会作为媒体显示;纯文本和 HTML 消息关闭了链接预览(网络连接) |
| 网络上的攻击者 | 没有向内的端口,无法直接接触 |
| 本机权限提升 | 没有 sudo / setuid;aipager 停留在用户空间 |
| 语音消息的音频泄露到云端 | 转写在本机进行 |
| 代理读取 aipager 自己的 Claude 登录信息 | 符合预期,不是 bug:见 Claude 的登录信息。限制登录信息本身,而不是存放它的文件 |