+shell 孪生版
唯一用加法 tag 的模块:默认不在,要了才有
shell 模块把运营者事先列过名的命令变成这个节点的能力,让网络另一端的 agent
可以触发它们并读回回显。它是本套件里唯一一个在宿主机上执行命令的模块,
也是唯一一个用加法 tag 的模块。
这一页讲发行层面的事:它在发行版矩阵里的位置、为什么 tag 方向反过来、 三道闸门分别由谁把守、怎么装、怎么核对手上的文件是哪一个变体。 配置字段、能力 id、收权操作的完整教程在教程四。
一 · 它不是第七档,是一个正交开关
六档发行版是同一条轴上的六个点,越往上模块越多。
shell 不在这条轴上 —— 它可以加在其中任意一档上:
所以严格说不是六档,是六对孪生版:同一档、同一 commit,一个能在宿主机上执行命令, 一个连这段代码都没有。
shell 一列在全部六档的默认形态里都是空的,full 也不例外。
这就是加法 tag 的全部意义:不写 -tags shell 就没有,再"全"的档也没有。
体积上它几乎不存在:各档上加 32 K 到 56 K 和 1 个依赖(在 full 上是 36 K;
实测数字见发行版总览第一节与第五节)。
它值得单独一个 tag 的理由与体积无关。
二 · 为什么这一个方向反过来
其余九个可选子系统一律"默认在、-tags no_<name> 去掉"。这一个相反:
默认不在,-tags shell 才编进去。
理由只有一条:两种 tag 写错的代价不对称。
减法 tag(no_x402 之类) | 加法 tag(shell) | |
|---|---|---|
| 写对了 | 模块被去掉 | 模块被编进去 |
| 漏写了 | 某人想去掉的模块没去掉 | 命令执行能力进了所有没要过它的构建 |
| 谁受影响 | 主动要求裁剪的人 | 从没听说过这个 tag 的人 |
需要靠"记得去掉"来保护的人,恰好是没听说过这个 tag 的人。 一个默认在的命令执行模块,保护的前提是每一个构建者都知道该去掉它 —— 这个前提在任何有第三方打包、有 CI 复制粘贴 tag 串的场景里都不成立。
module/shell/shell.go 的包注释把这句话写成了代码的一部分:
CI 因此把它单列成一个 job(optin),而不是在 pluggable 矩阵里多加一行 ——
两个 job 的断言方向是相反的,合并会让其中一个失去意义。
三 · 三道闸门
"这个二进制有这个能力"、"这台机器现在接受远程命令"、"这个调用方可以调" 是三个独立决定,由三个不同的人在三个不同的时刻做出。任何一道没过,命令都不会执行。
| 闸门 | 谁决定 | 什么时候 | 默认 | 不满足时的表现 |
|---|---|---|---|---|
| 1 · 编进去 | 构建者 | 编译期 | 不在 | anet version 的 modules: 行里没有 shell;go tool nm 数 module/shell 得 0 |
| 2 · 配起来 | 运营者 | 写 config.json 时 | 无配置块 | newModule 拿到空配置返回 nil, nil,不注册任何能力,不执行任何命令 |
| 3 · 放进名单 | 运营者 | 用 allow_file 时随时改、下一次调用即生效;写在 allow 里要重启 | 空名单 | 所有远程调用回 UNAVAILABLE,并在证据链上记一条 anet.shell.refused |
三道闸门的位置分别在:.github/workflows/ci.yml 的 optin job 所验的链接结果、
module/shell/shell.go 的 newModule、module/shell/provider.go 的 Invoke。
第三道有两个容易误解的点:
空名单是拒绝所有,不是放行所有。 allow 与 allow_file 都为空时,
每一个带身份的调用都被拒。daemon 启动时会在 stderr 上直说这件事:
不带调用方身份的调用同样默认拒绝。 CallerAID 为空时要显式 allow_local: true
才放行。这条不是为了防谁,是为了防字段忘填:CallerAID 的零值就是空字符串,
把空当作"本机、可信"意味着将来任何一处新调用点漏填,都会静默绕过整份名单。
默认拒绝让同一个疏忽表现为一次看得见的拒绝。
还有一个独立的第四个开关:allow_arbitrary。它控制的是 shell.exec
(执行调用方给的任意命令),默认关。关着的时候 shell.exec 既不出现在节点的能力列表里,
也不接受按名调用。"我配了几条运维命令"和"我在这台机器上开了一个 shell"
因此是两个决定,不是同一个。
被拒的调用回的是 UNAVAILABLE 而不是 FAILED。这是诚实效果状态
的区分:UNAVAILABLE 是"这个节点不为你做这件事",FAILED 是"做了但没成"。
调用方据此能分清是自己没权限,还是命令本身出了问题。
四 · 判据也反过来
可插拔的判据是符号数,不是"运行时禁用"。对减法 tag,断言是"带 tag 的构建里必须是 0";
对 shell,断言方向整个翻转:
两个方向都要验。 只验 -tags shell 那一侧大于 0,证明的只是"这个 tag 有用",
证明不了默认构建是干净的;只验默认构建等于 0,一个从未被链接进任何地方的模块
也能轻松通过。CI 的 optin job 每次提交把这两条都跑一遍,任何一条不成立就红。
还有一件只有加法 tag 才需要操心的事
加法 tag 下的代码,go test ./... 根本看不见。
module/shell/ 下三个文件(shell.go、provider.go、shell_test.go)开头都是
//go:build shell。默认的测试命令不带这个 tag,于是这三个文件连同它们的测试
一起被构建约束排除掉 —— 测试不是失败,是不存在。CI 里那条常规的
go test ./... 全绿,而这个模块的测试哪儿都没跑。
所以 optin job 必须另有一条带 tag 的:
这是加法 tag 相对减法 tag 多出来的一项维护成本。减法 tag 没有这个问题:
默认构建里模块是在的,它的测试跟着 go test ./... 一起跑。
新增任何加法 tag 的模块时,除了在 optin 矩阵里加一行,还必须确认
go test -tags <name> ./... 那一步覆盖到它。漏了这一步,该模块的测试
在整条流水线上没有任何地方会执行,而没有任何一条红灯会提示这件事。
五 · 装它
一行安装脚本默认拿到的是不含 shell 的那一个。要孪生版必须显式加 flag:
--shell 下载的是另一个文件(anet-shell-<os>-<arch>.gz),不是给同一个二进制
传的开关。安装脚本下载前打印的那一行会写明拿到的是哪个变体:
装完直接接上 hub 的完整形式:
从源码编
只要 shell 一个 tag(即 full+shell):
或者用带 commit 戳与 tag 戳的构建脚本:
要某一档的孪生版,把该档的减法 tag 串和 shell 拼在一起。例如 standard+shell:
scripts/build.sh 的 ldflags 是 -s -w,-s 会连符号表一起去掉。
用它编出来的二进制上,go tool nm 不输出任何行,第四节那两条核对都做不了。
发布脚本 deploy/release/build-release.sh 只用 -w,故意保留符号表,
正是为了让下游能在拿到的文件上把符号数出来。要自己编一个可核对的构建,
把 ldflags 里的 -s 去掉。
当前实际发布的两个件
v0.1.7 每个平台只出两个件,四平台共八个:
| 资产名 | tag | 相当于哪一档 |
|---|---|---|
anet-<os>-<arch> | 无 | full,不含 shell |
anet-shell-<os>-<arch> | shell | full+shell |
也就是说,六对孪生版里目前只有 full 这一对有现成的发布件,其余五对要自己编。
ANet/docs/DISTRIBUTIONS-zh.md 的发行建议正好相反 —— 它建议先发
standard+shell 与 agent+shell,不发 full+shell。这两者不一致,见第九节。
六 · 核对手上这个文件是哪一个变体
孪生版的名字必须说清是哪一个,但名字是可以改的,文件是可以被换的。 下面三条查的是文件本身。
一、跑一下它:
输出两行,第二行是模块列表。含 shell 的构建会把它列在里面:
上面这一份是默认版 —— modules: 行里没有 shell。
commit 与 built 显示 unknown,是因为这一份是没打 ldflags 戳的本机构建;
deploy/release/build-release.sh 出的发布件在这两个位置是真实的 commit 与构建时间。
modules: 这一行读的是模块注册表(init() 跑过才在里面),不是构建时刻的 tag 字符串,
所以它不会因为 ldflags 忘了传而说谎。
二、不跑它,数符号(需要 Go 工具链):
默认版答 0,anet-shell-* 答大于 0。
三、不跑它,也没有 Go 工具链:
同样是默认版 0、shell 变体大于 0。核对手段的完整四层见 核对一个构建。
四、看 daemon 日志。 节点起来后,internal/daemon/modules.go 会打一行:
anet logs 能读到。
anet status 不报模块,别拿它判断变体。
七 · 安全须知
不提权。 命令以 daemon 自己的用户身份运行。要 root 就要让 daemon 以 root 运行,
那是安装期的决定,写在 systemd unit 的 User= 里、看得见、可审计。
本模块不调用 sudo、不 setuid、不提供任何抬升身份的字段。
User=root 的 daemon 才能跑 root 命令,User= 是你自己就只能跑你自己能跑的
(unit 写法见 ANet/docs/INSTALL-DEBIAN-zh.md;一行安装脚本的 --system
只是把二进制装到 /usr/local/bin,它不创建 systemd unit)。
命令名会进 hub 目录。 节点向 hub 通告的是它实际会应答的能力 id,
所以 shell.run@restart-app 这类名字对任何能浏览目录的人可见。
这不给出访问权 —— 名单仍然拦着,不在名单上的调用一律 UNAVAILABLE ——
但它确实公开了"这台机器可以被要求做什么"。不希望公开的信息,不要写进命令名。
名单条数会回给调用方,AID 列表不会。 一个被接受的调用方调 shell.list
拿到的是命令清单、allow_arbitrary 状态、超时值和名单条数,
不含名单里的 AID。一个被接受的调用方不该顺带拿到本节点信任的其余机器名单。
只有 CallerAID 为空的本机调用才拿到完整 AID 列表,那是运营者在读自己的配置。
参数不会变成命令。 args: true 时调用方给的 argv 逐个单引号包裹后追加。
命令行归运营者、参数归网络,两者不能混成一个东西。
运营者自己写进 run 的内容不受限制。 run 经 /bin/sh -c 执行,
管道、重定向、&& 都能用 —— 这是有意的取舍:不让运营者写管道,
他们会把命令包进一个脚本,配置文件里就只剩一个脚本路径,可见性反而更差。
安全边界划在"运营者写的"和"网络传来的"之间,后者才是被引号隔离的那一侧。
每次执行都上证据链。 事件 anet.shell.command 记录谁调的、跑了什么、
退出码、耗时、输出字节数;被拒的调用记 anet.shell.refused,含调用方 AID 与拒绝原因。
两条都写在回显返回之前;写入失败不阻断已经执行的命令。
回显也会上链,但不是由本模块写的。 anet.shell.command 事件里确实没有回显,
可回显作为 effect 的 evidence.observed_state 回给调用方,内核
(internal/daemon/capability.go 的 deliverCapabilityResult)再把同一份 evidence
写进本节点的 anet.capability.effect 记录。所以命令打印出来的东西会落在本节点的
证据链上,上限是 max_output_bytes(默认 64 KiB)。不希望上链的内容,不要让命令打印。
超时杀整个进程组。 只杀 shell 会把它启动的子进程留在机器上, 没有句柄能再找到它们。
八 · 覆盖它的测试
单元测试自己构造 provider.Call,断言的是测试文件自己写进去的 CallerAID;
整套授权都压在这一个字段上,而"到达 Invoke 的是验签后的身份、不是调用方自称的值"
只有真委派能证明。所以有两层跨进程覆盖:
| 脚本 | 起点 | 验什么 |
|---|---|---|
scripts/joint-shell.sh | 本机起一个 hub 加两个 daemon | 7 组断言:名单文件不存在时拒绝、以 daemon 的用户身份执行、到达模块的是验签身份、参数元字符不被解释、非零退出回 FAILED 且 stderr 完整、删名单行后下一次调用即被拒且加回即恢复、shell.exec 不出现在目录里且按名调用不执行 |
scripts/container-shell-test.sh | 一个什么都没装的 debian:12 容器 | 8 组断言,走线上 install.sh --shell 装、经 hub.agentnetwork.org.cn 从另一台机器调用。多验了超时杀进程组一项 |
两层的区别在起点。前者验接缝,后者验真实新用户会走的那条路 ——
只有后者能查出白板系统缺什么(debian:12 既无 curl 也无 ca-certificates),
以及"改了模块配置、重启了,但能力不在 hub 目录里"这类不报错、只是让节点查不到的问题
(那一条的成因已经修掉:daemon 启动时会重新注册,把当前模块提供的能力清单折进去)。
joint-shell.sh 用 -tags shell 自己编被测二进制。这一步是必须的:
对着默认构建跑这个脚本,每一条拒绝用例都会因为错误的原因通过。
九 · 已知缺口
按位置、行为、影响、发现方式记录。
1. 发布件与设计稿不一致。 位置:ANet/deploy/release/build-release.sh 的
build_one "$plat" "shell" "anet-shell"。行为:发的 shell 变体是 full+shell。
影响:DISTRIBUTIONS-zh.md 的发行建议明确说不发这个组合(full 已带 p2p 与 x402,
再叠 shell 是公开面最大的组合),而它正是现在在发的两个件之一;
它建议发的 standard+shell 与 agent+shell 则没有发布件。
发现方式:核对发布脚本与设计稿的发行建议一节。
2. CI 只验 shell 单独一个 tag,不验任何组合。 位置:
.github/workflows/ci.yml —— optin 矩阵是 tag: ["shell"],pluggable 矩阵的
18 个 tag 串里没有一个含 shell。行为:shell 与减法 tag 拼在一起的构建
(即除 full+shell 外的五对孪生版)从来没有在 CI 里编译或测试过。
影响:这些组合在 2026-09-03 的实测里逐个编过(见发行版总览
第五节),但没有任何自动检查在维持这件事 —— 哪一次改动让它们编不过,CI 都不会红。
发现方式:通读 ci.yml 两个矩阵的 tag 列表。
3. scripts/build.sh 用 -s 剥掉符号表。 位置:scripts/build.sh 的
-ldflags "-s -w ..."。行为:产物上 go tool nm 无符号可数。
影响:用这个脚本编出来的二进制,第四节那两条、以及第六节的第二条做不了 ——
go tool nm 无符号表时不输出任何行,接在后面的 grep -c module/shell 老老实实数出 0,
看起来像通过,实际什么都没证明。第六节的第三条(strings)不受影响:-s 去掉的是
符号表,函数名表还在,计数变小但零/非零的区分不变。
发布脚本刻意不用 -s,两者标准不一致。发现方式:对比两个脚本的 ldflags。
4. 没有基于内容的命令白名单。 位置:module/shell/shell.go 的 Config.validate。
行为:只校验命令名不含空格、制表符、@、/,以及 run 非空
(另外校验超时与输出上限的取值范围、allow 与 allow_file 不得同时设);
run 的内容不做任何检查。
影响:这是有意的取舍(见第七节),但它意味着"配了 shell 模块"与
"这台机器上有一条运营者亲手写的、不受限的命令行"是同一件事。
发现方式:读 validate 的实现。