AGENT NETWORK · DOCS
发行版

+shell 孪生版

唯一用加法 tag 的模块:默认不在,要了才有

shell 模块把运营者事先列过名的命令变成这个节点的能力,让网络另一端的 agent 可以触发它们并读回回显。它是本套件里唯一一个在宿主机上执行命令的模块, 也是唯一一个用加法 tag 的模块。

这一页讲发行层面的事:它在发行版矩阵里的位置、为什么 tag 方向反过来、 三道闸门分别由谁把守、怎么装、怎么核对手上的文件是哪一个变体。 配置字段、能力 id、收权操作的完整教程在教程四。


一 · 它不是第七档,是一个正交开关

六档发行版是同一条轴上的六个点,越往上模块越多。 shell 不在这条轴上 —— 它可以加在其中任意一档上:

min      standard      paid      agent      p2p      full
 │          │            │         │         │        │
 └── +shell ┴── +shell ──┴─ +shell ┴─ +shell ┴ +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 的包注释把这句话写成了代码的一部分:

a tag you must remember to remove protects nobody who did not know to remove it

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 上直说这件事:

anet shell: no caller is allowed — set allow or allow_file, or this module will refuse every call

不带调用方身份的调用同样默认拒绝。 CallerAID 为空时要显式 allow_local: true 才放行。这条不是为了防谁,是为了防字段忘填:CallerAID 的零值就是空字符串, 把空当作"本机、可信"意味着将来任何一处新调用点漏填,都会静默绕过整份名单。 默认拒绝让同一个疏忽表现为一次看得见的拒绝。

还有一个独立的第四个开关:allow_arbitrary。它控制的是 shell.exec (执行调用方给的任意命令),默认关。关着的时候 shell.exec 既不出现在节点的能力列表里, 也不接受按名调用。"我配了几条运维命令"和"我在这台机器上开了一个 shell" 因此是两个决定,不是同一个。

被拒的调用回的是 UNAVAILABLE 而不是 FAILED。这是诚实效果状态 的区分:UNAVAILABLE 是"这个节点不为你做这件事",FAILED 是"做了但没成"。 调用方据此能分清是自己没权限,还是命令本身出了问题。


四 · 判据也反过来

可插拔的判据是符号数,不是"运行时禁用"。对减法 tag,断言是"带 tag 的构建里必须是 0"; 对 shell,断言方向整个翻转:

go build -o /tmp/anet-default ./cmd/anet
go build -tags shell -o /tmp/anet-optin ./cmd/anet
go tool nm /tmp/anet-default | grep -c module/shell   # 必须 == 0
go tool nm /tmp/anet-optin   | grep -c module/shell   # 必须 > 0

两个方向都要验。 只验 -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 的:

go vet -tags shell ./...
go test -tags shell ./...

这是加法 tag 相对减法 tag 多出来的一项维护成本。减法 tag 没有这个问题: 默认构建里模块是在的,它的测试跟着 go test ./... 一起跑。

新增任何加法 tag 的模块时,除了在 optin 矩阵里加一行,还必须确认 go test -tags <name> ./... 那一步覆盖到它。漏了这一步,该模块的测试 在整条流水线上没有任何地方会执行,而没有任何一条红灯会提示这件事。


五 · 装它

一行安装脚本默认拿到的是不含 shell 的那一个。要孪生版必须显式加 flag:

curl -fsSL https://agentnetwork.org.cn/install.sh | sh -s -- --shell

--shell 下载的是另一个文件(anet-shell-<os>-<arch>.gz),不是给同一个二进制 传的开关。安装脚本下载前打印的那一行会写明拿到的是哪个变体:

→ Installing anet for linux-amd64 (shell variant: CAN run operator-approved commands on this machine)

装完直接接上 hub 的完整形式:

curl -fsSL https://agentnetwork.org.cn/install.sh | sh -s -- --shell --system \
  --hub https://hub.agentnetwork.org.cn --name my-box

从源码编

只要 shell 一个 tag(即 full+shell):

go build -tags shell -o anet-shell ./cmd/anet

或者用带 commit 戳与 tag 戳的构建脚本:

TAGS=shell OUT=./anet-shell bash scripts/build.sh

要某一档的孪生版,把该档的减法 tag 串和 shell 拼在一起。例如 standard+shell:

go build -tags "shell,no_x402,no_mcp,no_taskboard,no_cas,no_org,no_blackboard,no_p2p,no_anetlink" -o anet-standard-shell ./cmd/anet

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>shellfull+shell

也就是说,六对孪生版里目前只有 full 这一对有现成的发布件,其余五对要自己编。 ANet/docs/DISTRIBUTIONS-zh.md 的发行建议正好相反 —— 它建议先发 standard+shell 与 agent+shell,不发 full+shell。这两者不一致,见第九节。


六 · 核对手上这个文件是哪一个变体

孪生版的名字必须说清是哪一个,但名字是可以改的,文件是可以被换的。 下面三条查的是文件本身。

一、跑一下它:

anet version

输出两行,第二行是模块列表。含 shell 的构建会把它列在里面:

anet 0.1.7 (commit unknown, built unknown)
modules: anetlink,blackboard,cas,mcp,org,p2p,service,taskboard,x402

上面这一份是默认版 —— modules: 行里没有 shell。 commit 与 built 显示 unknown,是因为这一份是没打 ldflags 戳的本机构建; deploy/release/build-release.sh 出的发布件在这两个位置是真实的 commit 与构建时间。

modules: 这一行读的是模块注册表(init() 跑过才在里面),不是构建时刻的 tag 字符串, 所以它不会因为 ldflags 忘了传而说谎。

二、不跑它,数符号(需要 Go 工具链):

go tool nm anet | grep -c module/shell

默认版答 0,anet-shell-* 答大于 0。

三、不跑它,也没有 Go 工具链:

strings anet | grep -c module/shell

同样是默认版 0、shell 变体大于 0。核对手段的完整四层见 核对一个构建。

四、看 daemon 日志。 节点起来后,internal/daemon/modules.go 会打一行:

anet: modules compiled in: <列表> (started: <数量>)

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 加两个 daemon7 组断言:名单文件不存在时拒绝、以 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 的实现。


接下来